From b407b205e56ec13c36f90b13c9008f457cc11176 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli Date: Tue, 15 Sep 2015 08:32:44 -0400 Subject: [PATCH 01/70] Added DTD Validation to makeNote/updateNote functions. These are for uploading content to Evernote's servers --- .gitignore | 1 + anknotes/Controller.py | 2 +- anknotes/ankEvernote.py | 77 +++++++++++- anknotes/constants.py | 1 + .../{third_party => ancillary}/enml2.dtd | 0 anknotes/stopwatch/__init__.py | 116 ++++++++++++++++++ anknotes/stopwatch/tests/__init__.py | 66 ++++++++++ 7 files changed, 256 insertions(+), 7 deletions(-) rename anknotes/extra/{third_party => ancillary}/enml2.dtd (100%) create mode 100644 anknotes/stopwatch/__init__.py create mode 100644 anknotes/stopwatch/tests/__init__.py diff --git a/.gitignore b/.gitignore index 1480bcb..cb64557 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ anknotes/extra/powergrep/ anknotes/extra/local/ anknotes/extra/testing/ anknotes/extra/anki_master +noteTest.py *.bk ################# diff --git a/anknotes/Controller.py b/anknotes/Controller.py index e467fed..94e4e89 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -116,7 +116,7 @@ def create_auto_toc(self): status, whole_note = self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid) if not whole_note: error += 1 - if status == 1: + if status == EvernoteAPIStatus.RateLimitError or status == EvernoteAPIStatus.SocketError: break else: continue diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 29b8bb1..4ad76ce 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- ### Python Imports import socket - +import stopwatch +from StringIO import StringIO +from lxml import etree try: from pysqlite2 import dbapi2 as sqlite except ImportError: @@ -19,7 +21,7 @@ from evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException from evernote.api.client import EvernoteClient -from aqt.utils import openLink +from aqt.utils import openLink, getText ### Anki Imports # import anki @@ -39,6 +41,7 @@ class Evernote(object): """:type : dict[str, anknotes.structs.EvernoteNotebook]""" tag_data = {} """:type : dict[str, anknotes.structs.EvernoteTag]""" + DTD = None def __init__(self): auth_token = mw.col.conf.get(SETTINGS.EVERNOTE_AUTH_TOKEN, False) @@ -87,6 +90,62 @@ def initialize_note_store(self): raise return 0 + def validateNoteBody(self, noteBody,title="Note Body"): + timerFull = stopwatch.Timer() + timerInterval = stopwatch.Timer(False) + + if not self.DTD: + timerInterval.reset() + log("Loading ENML DTD", "lxml") + self.DTD = etree.DTD(ANKNOTES.ENML_DTD) + log("DTD Loaded in %s" % str(timerInterval), "lxml") + timerInterval.stop() + + timerInterval.reset() + log("Loading XML for %s" % title, "lxml") + try: + tree = etree.parse(noteBody) + except Exception as e: + timer_header = ' at %s. The whole process took %s' % (str(timerInterval), str(timerFull)) + log_str = "XML Loading of %s failed.%s\n - Error Details: %s" + log_str_error = log_str % (title, '', str(e)) + log_str = log_str % (title, timer_header, str(e)) + log(log_str, "lxml") + log_error(log_str_error) + return False + log("XML Loaded in %s" % str(timerInterval), "lxml") + # timerInterval.stop() + timerInterval.reset() + log("Validating %s with ENML DTD" % title, "lxml") + try: + success = self.DTD.validate(tree) + except Exception as e: + timer_header = ' at %s. The whole process took %s' % (str(timerInterval), str(timerFull)) + log_str = "DTD Validation of %s failed.%s\n - Error Details: %s" + log_str_error = log_str % (title, '', str(e)) + log_str = log_str % (title, timer_header, str(e)) + log(log_str, "lxml") + log_error(log_str_error) + return False + log("Validation %s in %s. Entire process took %s" % ("Succeeded" if success else "Failed", str(timerInterval), str(timerFull)), "lxml") + if not success: + log_str = "DTD Validation Errors for %s: \n%s\n" % (title, self.DTD.error_log.filter_from_errors()) + log(log_str) + log_error(log_str) + timerInterval.stop() + timerFull.stop() + del timerInterval + del timerFull + return success + + def validateNoteContent(self, content, title="Note Contents"): + """ + + :param content: Valid ENML without the tags. Will be processed by makeNoteBody + :return: + """ + return self.validateNoteBody(self.makeNoteBody(content), title) + def updateNote(self, guid, noteTitle, noteBody, tagNames=list(), parentNotebook=None, resources=[]): """ Update a Note instance with title and body @@ -98,12 +157,12 @@ def updateNote(self, guid, noteTitle, noteBody, tagNames=list(), parentNotebook= guid=guid) @staticmethod - def makeNoteBody(noteBody, resources=[], encode=True): + def makeNoteBody(content, resources=[], encode=True): ## Build body of note nBody = "" nBody += "" - nBody += "%s" % noteBody + nBody += "%s" % content # if resources: # ### Add Resource objects to note body # nBody += "
" * 2 @@ -117,11 +176,12 @@ def makeNoteBody(noteBody, resources=[], encode=True): nBody = nBody.encode('utf-8') return nBody - def makeNote(self, noteTitle, noteBody, tagNames=list(), parentNotebook=None, resources=[], guid=None): + def makeNote(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=[], guid=None): """ Create or Update a Note instance with title and body Send Note object to user's account :type noteTitle: str + :param noteContents: Valid ENML without the tags. Will be processed by makeNoteBody :rtype : (EvernoteAPIStatus, EvernoteNote) :returns Status and Note """ @@ -134,7 +194,9 @@ def makeNote(self, noteTitle, noteBody, tagNames=list(), parentNotebook=None, re ourNote.guid = guid ## Build body of note - nBody = self.makeNoteBody(noteBody, resources) + nBody = self.makeNoteBody(noteContents, resources) + if not self.validateNoteBody(nBody, ourNote.title): + return EvernoteAPIStatus.UserError, None ourNote.content = nBody if '' in tagNames: tagNames.remove('') @@ -327,3 +389,6 @@ def get_tag_names_from_evernote_guids(self, tag_guids_original): DEBUG_RAISE_API_ERRORS = False + +testEN = Evernote() +testEN.validateNoteBody("Test") \ No newline at end of file diff --git a/anknotes/constants.py b/anknotes/constants.py index c730fac..8ec3c8b 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -11,6 +11,7 @@ class ANKNOTES: LOG_BASE_NAME = 'anknotes' TEMPLATE_FRONT = os.path.join(FOLDER_ANCILLARY, 'FrontTemplate.htm') CSS = u'_AviAnkiCSS.css' + ENML_DTD = os.path.join(FOLDER_ANCILLARY, 'enml2.dtd') ICON_EVERNOTE_WEB = os.path.join(FOLDER_GRAPHICS, u'evernote_web.ico') IMAGE_EVERNOTE_WEB = ICON_EVERNOTE_WEB.replace('.ico', '.png') ICON_EVERNOTE_ARTCORE = os.path.join(FOLDER_GRAPHICS, u'evernote_artcore.ico') diff --git a/anknotes/extra/third_party/enml2.dtd b/anknotes/extra/ancillary/enml2.dtd similarity index 100% rename from anknotes/extra/third_party/enml2.dtd rename to anknotes/extra/ancillary/enml2.dtd diff --git a/anknotes/stopwatch/__init__.py b/anknotes/stopwatch/__init__.py new file mode 100644 index 0000000..f1446fc --- /dev/null +++ b/anknotes/stopwatch/__init__.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2008 John Paulett (john -at- 7oars.com) +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. + +import time +from anknotes.logging import log + +"""stopwatch is a very simple Python module for measuring time. +Great for finding out how long code takes to execute. + +>>> import stopwatch +>>> t = stopwatch.Timer() +>>> t.elapsed +3.8274309635162354 +>>> print t +15.9507198334 sec +>>> t.stop() +30.153270959854126 +>>> print t +30.1532709599 sec + +Decorator exists for printing out execution times: +>>> from stopwatch import clockit +>>> @clockit + def mult(a, b): + return a * b +>>> print mult(2, 6) +mult in 1.38282775879e-05 sec +6 + +""" + +__version__ = '0.3.1' +__author__ = 'John Paulett ' + +class Timer(object): + __times__ = [] + __stopped = None + def __init__(self, begin=True): + if begin: + self.reset() + + def start(self): + self.reset() + + def reset(self): + if self.__stopped: + self.__times__.append(self.elapsed) + self.__stopped = None + self.__start = self.__time() + + def stop(self): + """Stops the clock permanently for the instance of the Timer. + Returns the time at which the instance was stopped. + """ + self.__stopped = self.__last_time() + return self.elapsed + + def elapsed(self): + """The number of seconds since the current time that the Timer + object was created. If stop() was called, it is the number + of seconds from the instance creation until stop() was called. + """ + return self.__last_time() - self.__start + elapsed = property(elapsed) + + def start_time(self): + """The time at which the Timer instance was created. + """ + return self.__start + start_time = property(start_time) + + def stop_time(self): + """The time at which stop() was called, or None if stop was + never called. + """ + return self.__stopped + stop_time = property(stop_time) + + def __last_time(self): + """Return the current time or the time at which stop() was call, + if called at all. + """ + if self.__stopped is not None: + return self.__stopped + return self.__time() + + def __time(self): + """Wrapper for time.time() to allow unit testing. + """ + return time.time() + + def __str__(self): + """Nicely format the elapsed time + """ + if self.elapsed < 60: + return str(self.elapsed) + ' sec' + m, s = divmod(self.elapsed, 60) + return '%dm %dsec' % (m, s) + +def clockit(func): + """Function decorator that times the evaluation of *func* and prints the + execution time. + """ + def new(*args, **kw): + t = Timer() + retval = func(*args, **kw) + t.stop() + log('Function %s completed in %s' % (func.__name__, t), "clockit") + del t + return retval + return new diff --git a/anknotes/stopwatch/tests/__init__.py b/anknotes/stopwatch/tests/__init__.py new file mode 100644 index 0000000..9c74fa6 --- /dev/null +++ b/anknotes/stopwatch/tests/__init__.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2008 John Paulett (john -at- 7oars.com) +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. + +import unittest +import doctest +import stopwatch +from stopwatch import clockit + +class TimeControlledTimer(stopwatch.Timer): + def __init__(self): + self.__count = 0 + super(TimeControlledTimer, self).__init__() + + def __time(self): + retval = self.__count + self.__count += 1 + return retval + + +class TimerTestCase(unittest.TestCase): + def setUp(self): + self.timer = stopwatch.Timer() + + def test_simple(self): + point1 = self.timer.elapsed + self.assertTrue(point1 > 0) + + point2 = self.timer.elapsed + self.assertTrue(point2 > point1) + + point3 = self.timer.elapsed + self.assertTrue(point3 > point2) + + def test_stop(self): + point1 = self.timer.elapsed + self.assertTrue(point1 > 0) + + self.timer.stop() + point2 = self.timer.elapsed + self.assertTrue(point2 > point1) + + point3 = self.timer.elapsed + self.assertEqual(point2, point3) + +@clockit +def timed_multiply(a, b): + return a * b + +class DecoratorTestCase(unittest.TestCase): + def test_clockit(self): + self.assertEqual(6, timed_multiply(2, b=3)) + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(TimerTestCase)) + suite.addTest(unittest.makeSuite(DecoratorTestCase)) + #suite.addTest(doctest.DocTestSuite(stopwatch)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') \ No newline at end of file From 54bb4c905d706540a979da9b3bad3090314cc6c1 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli Date: Wed, 16 Sep 2015 12:31:24 -0400 Subject: [PATCH 02/70] 1. Move lxml code out of addon and into separate command-line script since unable to bundle lxml 2. Integrated command line utility with Anknotes' Anki menu --- anknotes/Anki.py | 37 +-- anknotes/AnkiNotePrototype.py | 12 +- anknotes/Controller.py | 288 +++++++++---------- anknotes/EvernoteImporter.py | 131 ++++----- anknotes/EvernoteNoteFetcher.py | 50 ++-- anknotes/EvernoteNotePrototype.py | 11 +- anknotes/EvernoteNoteTitle.py | 90 +++--- anknotes/EvernoteNotes.py | 82 ++++-- anknotes/__main__.py | 3 + anknotes/ankEvernote.py | 124 ++++++--- anknotes/constants.py | 11 +- anknotes/db.py | 9 +- anknotes/error.py | 4 +- anknotes/find_deleted_notes.py | 1 - anknotes/graphics.py | 2 +- anknotes/html.py | 45 +-- anknotes/logging.py | 40 ++- anknotes/menu.py | 63 ++++- anknotes/settings.py | 12 +- anknotes/shared.py | 3 +- anknotes/stopwatch/__init__.py | 29 +- anknotes/stopwatch/tests/__init__.py | 31 ++- anknotes/structs.py | 399 +++++++++++++++------------ anknotes/toc.py | 74 +++-- anknotes_standAlone.py | 121 ++++++++ test.py | 3 + 26 files changed, 1022 insertions(+), 653 deletions(-) create mode 100644 anknotes_standAlone.py create mode 100644 test.py diff --git a/anknotes/Anki.py b/anknotes/Anki.py index 94bd8bb..4dc3615 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -19,11 +19,12 @@ # from evernote.api.client import EvernoteClient ### Anki Imports -import anki -from anki.notes import Note as AnkiNote -import aqt -from aqt import mw - +try: + import anki + from anki.notes import Note as AnkiNote + import aqt + from aqt import mw +except: pass DEBUG_RAISE_API_ERRORS = False @@ -92,11 +93,11 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang if isinstance(content, str): content = unicode(content, 'utf-8') anki_field_info = { - FIELDS.TITLE: title, - FIELDS.CONTENT: content, - FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + ankiNote.Guid, + FIELDS.TITLE: title, + FIELDS.CONTENT: content, + FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + ankiNote.Guid, FIELDS.UPDATE_SEQUENCE_NUM: str(ankiNote.UpdateSequenceNum), - FIELDS.SEE_ALSO: u'' + FIELDS.SEE_ALSO: u'' } except: log_error("Unable to set field info for: Note '%s': '%s'" % (ankiNote.Title, ankiNote.Guid)) @@ -211,9 +212,11 @@ def add_evernote_model(self, mm, modelName, cloze=False): self.evernoteModels[modelName] = model['id'] def get_templates(self): - field_names = {"Title": FIELDS.TITLE, "Content": FIELDS.CONTENT, "Extra": FIELDS.EXTRA, - "See Also": FIELDS.SEE_ALSO, "TOC": FIELDS.TOC, "Outline": FIELDS.OUTLINE, - "Evernote GUID Prefix": FIELDS.EVERNOTE_GUID_PREFIX, "Evernote GUID": FIELDS.EVERNOTE_GUID} + field_names = { + "Title": FIELDS.TITLE, "Content": FIELDS.CONTENT, "Extra": FIELDS.EXTRA, + "See Also": FIELDS.SEE_ALSO, "TOC": FIELDS.TOC, "Outline": FIELDS.OUTLINE, + "Evernote GUID Prefix": FIELDS.EVERNOTE_GUID_PREFIX, "Evernote GUID": FIELDS.EVERNOTE_GUID + } if not self.templates: # Generate Front and Back Templates from HTML Template in anknotes' addon directory self.templates = {"Front": file(ANKNOTES.TEMPLATE_FRONT, 'r').read() % field_names} @@ -463,8 +466,8 @@ def insert_toc_and_outline_contents_into_notes(self): toc_count = 0 outline_count = 0 toc_and_outline_links = ankDB().execute( - "select target_evernote_guid, is_toc, is_outline from %s WHERE source_evernote_guid = '%s' AND (is_toc = 1 OR is_outline = 1) ORDER BY number ASC" % ( - TABLES.SEE_ALSO, source_evernote_guid)) + "select target_evernote_guid, is_toc, is_outline from %s WHERE source_evernote_guid = '%s' AND (is_toc = 1 OR is_outline = 1) ORDER BY number ASC" % ( + TABLES.SEE_ALSO, source_evernote_guid)) for target_evernote_guid, is_toc, is_outline in toc_and_outline_links: if target_evernote_guid in linked_notes_fields: linked_note_contents = linked_notes_fields[target_evernote_guid][FIELDS.CONTENT] @@ -479,8 +482,10 @@ def insert_toc_and_outline_contents_into_notes(self): elif FIELDS.TITLE in fld.get('name'): linked_note_title = linked_note.fields[fld.get('ord')] if linked_note_contents: - linked_notes_fields[target_evernote_guid] = {FIELDS.TITLE: linked_note_title, - FIELDS.CONTENT: linked_note_contents} + linked_notes_fields[target_evernote_guid] = { + FIELDS.TITLE: linked_note_title, + FIELDS.CONTENT: linked_note_contents + } if linked_note_contents: if isinstance(linked_note_contents, str): linked_note_contents = unicode(linked_note_contents, 'utf-8') diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 44a7c16..2715993 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -4,11 +4,12 @@ from anknotes.EvernoteNoteTitle import EvernoteNoteTitle ### Anki Imports -import anki -from anki.notes import Note as AnkiNote + try: - from aqt import mw + import anki + from anki.notes import Note as AnkiNote + from aqt import mw except: pass @@ -38,10 +39,12 @@ class AnkiNotePrototype: NotebookGuid = "" """:type : str""" __cloze_count__ = 0 + class Counts: Updated = 0 Current = 0 Max = 1 + OriginalGuid = None """:type : str""" Changed = False @@ -425,7 +428,8 @@ def update_note(self): if not (self.Changed or self.update_note_deck()): if self._log_update_if_unchanged_: self.log_update("Not updating Note: The fields, tags, and deck are the same") - elif (self.Counts.Updated is 0 or self.Counts.Current / self.Counts.Updated > 9) and self.Counts.Current % 100 is 0: + elif ( + self.Counts.Updated is 0 or self.Counts.Current / self.Counts.Updated > 9) and self.Counts.Current % 100 is 0: self.log_update() return False # i.e., the note deck has been changed but the tags and fields have not diff --git a/anknotes/Controller.py b/anknotes/Controller.py index 94e4e89..158f1f7 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -13,20 +13,21 @@ ### Anknotes Class Imports from anknotes.AnkiNotePrototype import AnkiNotePrototype -from toc import generateTOCTitle +from anknotes.EvernoteNoteTitle import generateTOCTitle ### Anknotes Main Imports from anknotes.Anki import Anki from anknotes.ankEvernote import Evernote from anknotes.EvernoteNotes import EvernoteNotes from anknotes.EvernoteNotePrototype import EvernoteNotePrototype +from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher from anknotes import settings from anknotes.EvernoteImporter import EvernoteImporter ### Evernote Imports -from evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec -from evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote -from evernote.edam.error.ttypes import EDAMSystemException +from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec +from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote +from anknotes.evernote.edam.error.ttypes import EDAMSystemException ### Anki Imports from aqt import mw @@ -35,9 +36,9 @@ class Controller: - evernoteImporter = None + evernoteImporter = None """:type : EvernoteImporter""" - + def __init__(self): self.forceAutoPage = False self.auto_page_callback = None @@ -50,9 +51,12 @@ def __init__(self): def test_anki(self, title, evernote_guid, filename=""): if not filename: filename = title - fields = {FIELDS.TITLE: title, - FIELDS.CONTENT: file(os.path.join(ANKNOTES.FOLDER_LOGS, filename.replace('.enex', '') + ".enex"), - 'r').read(), FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid} + fields = { + FIELDS.TITLE: title, + FIELDS.CONTENT: file( + os.path.join(ANKNOTES.FOLDER_LOGS, filename.replace('.enex', '') + ".enex"), + 'r').read(), FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid + } tags = ['NoTags', 'NoTagsToRemove'] return AnkiNotePrototype(self.anki, fields, tags) @@ -62,20 +66,99 @@ def process_unadded_see_also_notes(self): self.evernote.getNoteCount = 0 self.anki.process_see_also_content(anki_note_ids) + def upload_validated_notes(self, automated=False): + self.anki.evernoteTags = [] + dbRows = ankDB().all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) + number_updated = 0 + number_created = 0 + count = 0 + count_create = 0 + count_update = 0 + exist = 0 + error = 0 + status = EvernoteAPIStatus.Uninitialized + notes_created = [] + """ + :type: list[EvernoteNote] + """ + notes_updated = [] + """ + :type: list[EvernoteNote] + """ + queries1 = [] + queries2 = [] + noteFetcher = EvernoteNoteFetcher() + if len(dbRows) == 0: + if not automated: + report_tooltips(" > Upload of Validated Notes Aborted", "No Qualifying Validated Notes Found") + return + for dbRow in dbRows: + entry = EvernoteValidationEntry(dbRow) + evernote_guid, rootTitle, contents, tagNames, notebookGuid = entry.items() + tagNames = tagNames.split(',') + if not ANKNOTES.UPLOAD_AUTO_TOC_NOTES or ( + ANKNOTES.AUTO_TOC_NOTES_MAX > -1 and count_update + count_create >= ANKNOTES.AUTO_TOC_NOTES_MAX): + continue + status, whole_note = self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid, + validated=True) + if status.IsError: + error += 1 + if status == EvernoteAPIStatus.RateLimitError or status == EvernoteAPIStatus.SocketError: + break + else: + continue + count += 1 + if status.IsSuccess: + noteFetcher.addNoteFromServerToDB(whole_note, tagNames) + note = EvernoteNotePrototype(whole_note=whole_note, tags=tagNames) + if evernote_guid: + notes_updated.append(note) + queries1.append([evernote_guid]) + count_update += 1 + else: + notes_created.append(note) + queries2.append([rootTitle, contents]) + count_create += 1 + if count_update + count_create > 0: + number_updated = self.anki.update_evernote_notes(notes_updated) + number_created = self.anki.add_evernote_notes(notes_created) + + str_tip_header = "%d of %d total Validated note(s) successfully generated." % (count, len(dbRows)) + str_tips = [] + if count_create: str_tips.append("%d Validated note(s) were newly created " % count_create) + if number_created: str_tips.append("-%d of these were successfully added to Anki " % number_created) + if count_update: str_tips.append( + "%d Validated note(s) already exist in local db and were updated" % count_update) + if number_updated: str_tips.append("-%d of these were successfully updated in Anki " % number_updated) + if error > 0: str_tips.append("%d error(s) occurred " % error) + report_tooltips(" > Upload of Validated Notes Complete", str_tip_header, str_tips) + + if len(queries1) > 0: + ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.MAKE_NOTE_QUEUE, queries1) + if len(queries2) > 0: + ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.MAKE_NOTE_QUEUE, queries2) + + return status, count, exist + def create_auto_toc(self): update_regex() self.anki.evernoteTags = [] NoteDB = EvernoteNotes() + NoteDB.baseQuery = ANKNOTES.ROOT_TITLES_BASE_QUERY dbRows = NoteDB.populateAllNonCustomRootNotes() + # dbRows = NoteDB.populateAllPotentialRootNotes() number_updated = 0 number_created = 0 count = 0 count_create = 0 count_update = 0 count_update_skipped = 0 + count_queued = 0 + count_queued_create = 0 + count_queued_update = 0 exist = 0 error = 0 - status = 0 + status = EvernoteAPIStatus.Uninitialized notes_created = [] """ :type: list[EvernoteNote] @@ -85,7 +168,7 @@ def create_auto_toc(self): :type: list[EvernoteNote] """ if len(dbRows) == 0: - report_tooltip(" > TOC Creation Aborted: No Qualifying Root Titles Found") + report_tooltips(" > TOC Creation Aborted", 'No Qualifying Root Titles Found') return for dbRow in dbRows: rootTitle, contents, tagNames, notebookGuid = dbRow.items() @@ -99,47 +182,65 @@ def create_auto_toc(self): if ANKNOTES.EVERNOTE_IS_SANDBOXED: tagNames.append("#Sandbox") rootTitle = generateTOCTitle(rootTitle) - evernote_guid, old_content = ankDB().first( + old_values = ankDB().first( "SELECT guid, content FROM %s WHERE UPPER(title) = ? AND tagNames LIKE '%%,' || ? || ',%%'" % TABLES.EVERNOTE.NOTES, rootTitle.upper(), EVERNOTE.TAG.AUTO_TOC) + evernote_guid = None noteBody = self.evernote.makeNoteBody(contents, encode=False) - if evernote_guid: + if old_values: + evernote_guid, old_content = old_values if old_content == noteBody: count += 1 count_update_skipped += 1 continue log(generate_diff(old_content, noteBody), 'AutoTOC-Create-Diffs') if not ANKNOTES.UPLOAD_AUTO_TOC_NOTES or ( - ANKNOTES.AUTO_TOC_NOTES_MAX > -1 and count_update + count_create > ANKNOTES.AUTO_TOC_NOTES_MAX): + ANKNOTES.AUTO_TOC_NOTES_MAX > -1 and count_update + count_create >= ANKNOTES.AUTO_TOC_NOTES_MAX): continue - self.evernote.initialize_note_store() status, whole_note = self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid) - if not whole_note: + if status.IsError: error += 1 if status == EvernoteAPIStatus.RateLimitError or status == EvernoteAPIStatus.SocketError: break else: continue + if status == EvernoteAPIStatus.RequestQueued: + count_queued += 1 + if old_values: count_queued_update += 1 + else: count_queued_create += 1 + continue count += 1 - note = EvernoteNotePrototype(whole_note=whole_note, tags=tagNames) - if evernote_guid: - count_update += 1 - notes_updated.append(note) - else: - count_create += 1 - notes_created.append(note) + if status.IsSuccess: + note = EvernoteNotePrototype(whole_note=whole_note, tags=tagNames) + if evernote_guid: + notes_updated.append(note) + count_update += 1 + else: + notes_created.append(note) + count_create += 1 if count_update + count_create > 0: number_updated = self.anki.update_evernote_notes(notes_updated) number_created = self.anki.add_evernote_notes(notes_created) - str_tip = "%d of %d total Auto TOC note(s) successfully generated." % (count, len(dbRows)) - if count_create: str_tip += "
--- %d Auto TOC note(s) were newly created " % count_create - if number_created: str_tip += "
--- %d of these were successfully added to Anki " % number_created - if count_update: str_tip += "
--- %d Auto TOC note(s) already exist in local db and were updated" % count_update - if number_updated: str_tip += "
--- %d of these were successfully updated in Anki " % number_updated - if count_update_skipped: str_tip += "
--- %d Auto TOC note(s) already exist in local db and were unchanged" % count_update_skipped - if error > 0: str_tip += "
--- %d error(s) occurred " % error - report_tooltip(" > TOC Creation Complete", str_tip) + str_tip_header = "%d of %d total Auto TOC note(s) successfully generated" % (count + count_queued, len(dbRows)) + str_tips = [] + if count_create: str_tips.append("%d Auto TOC note(s) were newly created " % count_create) + if number_created: str_tips.append("-%d of these were successfully added to Anki " % number_created) + if count_queued_create: str_tips.append( + "%d new Auto TOC note(s) were queued to be added to Anki " % count_queued_create) + if count_update: str_tips.append( + "%d Auto TOC note(s) already exist in local db and were updated" % count_update) + if number_updated: str_tips.append("-%d of these were successfully updated in Anki " % number_updated) + if count_queued_update: str_tips.append( + "%d Auto TOC note(s) already exist in local db and were queued to be updated in Anki" % count_queued_update) + if count_update_skipped: str_tips.append( + "%d Auto TOC note(s) already exist in local db and were unchanged" % count_update_skipped) + if error > 0: str_tips.append("%d error(s) occurred " % error) + report_tooltips(" > TOC Creation Complete: ", str_tip_header, str_tips) + + if count_queued > 0: + ankDB().commit() + return status, count, exist def update_ancillary_data(self): @@ -154,129 +255,6 @@ def proceed(self, auto_paging=False): self.evernoteImporter.auto_page_callback = self.auto_page_callback self.evernoteImporter.currentPage = self.currentPage self.evernoteImporter.proceed(auto_paging) - - def proceed_full(self, auto_paging=False): - global latestEDAMRateLimit, latestSocketError - col = self.anki.collection() - autoPagingEnabled = (col.conf.get(SETTINGS.EVERNOTE_AUTO_PAGING, True) or self.forceAutoPage) - lastImport = col.conf.get(SETTINGS.EVERNOTE_LAST_IMPORT, None) - col.conf[SETTINGS.EVERNOTE_LAST_IMPORT] = datetime.now().strftime(ANKNOTES.DATE_FORMAT) - col.setMod() - col.save() - lastImportStr = get_friendly_interval_string(lastImport) - if lastImportStr: - lastImportStr = ' [LAST IMPORT: %s]' % lastImportStr - log("! > Starting Evernote Import: Page #%d: %-60s%s" % ( - self.currentPage, settings.generate_evernote_query(), lastImportStr)) - log( - "-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------", - timestamp=False) - if not auto_paging: - if not hasattr(self.evernote, 'noteStore'): - log(" > Note store does not exist. Aborting.") - return False - self.evernote.getNoteCount = 0 - - - anki_note_ids = self.anki.get_anknotes_note_ids() - anki_evernote_guids = self.anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) - - status, MetadataProgress, server_evernote_guids, self.evernote.metadata = self.get_evernote_metadata() - if status == EvernoteAPIStatus.RateLimitError: - m, s = divmod(latestEDAMRateLimit, 60) - report_tooltip(" > Error: Delaying Operation", - "Over the rate limit when searching for Evernote metadata
Evernote requested we wait %d:%02d min" % ( - m, s), delay=5) - mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(auto_paging), False) - return False - elif status == EvernoteAPIStatus.SocketError: - report_tooltip(" > Error: Delaying Operation:", - "%s when searching for Evernote metadata
We will try again in 30 seconds" % - latestSocketError['friendly_error_msg'], delay=5) - mw.progress.timer(30000, lambda: self.proceed(auto_paging), False) - return False - - ImportProgress = EvernoteImportProgress(self.anki, server_evernote_guids) - ImportProgress.loadAlreadyUpdated(self.check_note_sync_status(ImportProgress.GUIDs.Updating)) - log(" - " + ImportProgress.Summary + "\n", timestamp=False) - - - - self.anki.start_editing() - ImportProgress.processResults(self.import_into_anki(ImportProgress.GUIDs.Adding)) - if self.updateExistingNotes is UpdateExistingNotes.UpdateNotesInPlace: - ImportProgress.processUpdateInPlaceResults(self.update_in_anki(ImportProgress.GUIDs.Updating)) - elif self.updateExistingNotes is UpdateExistingNotes.DeleteAndUpdate: - self.anki.delete_anki_cards(ImportProgress.GUIDs.Updating) - ImportProgress.processDeleteAndUpdateResults(self.import_into_anki(ImportProgress.GUIDs.Updating)) - report_tooltip(" > Import Complete", Import.ResultsSummary) - self.anki.stop_editing() - col.autosave() - if not autoPagingEnabled: - return - - status = ImportProgress.Status - restart = 0 - restart_msg = "" - restart_title = None - suffix = "" - if status == EvernoteAPIStatus.RateLimitError: - m, s = divmod(latestEDAMRateLimit, 60) - report_tooltip(" > Error: Delaying Auto Paging", - "Over the rate limit when getting Evernote notes
Evernote requested we wait %d:%02d min" % ( - m, s), delay=5) - mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(True), False) - return False - if status == EvernoteAPIStatus.SocketError: - report_tooltip(" > Error: Delaying Auto Paging:", - "%s when getting Evernote notes
We will try again in 30 seconds" % latestSocketError[ - 'friendly_error_msg'], delay=5) - mw.progress.timer(30000, lambda: self.proceed(True), False) - return False - if MetadataProgress.Completed: - self.currentPage = 1 - if self.forceAutoPage: - report_tooltip(" > Terminating Auto Paging", - "All %d notes have been processed and forceAutoPage is True" % MetadataProgress.Total, - delay=5) - self.auto_page_callback() - elif col.conf.get(EVERNOTE.PAGING_RESTART_WHEN_COMPLETE, True): - restart = EVERNOTE.PAGING_RESTART_INTERVAL - restart_title = " > Restarting Auto Paging" - restart_msg = "All %d notes have been processed and EVERNOTE.PAGING_RESTART_WHEN_COMPLETE is TRUE
" % \ - MetadataProgress.Total - suffix = "Per EVERNOTE.PAGING_RESTART_INTERVAL, " - else: - report_tooltip(" > Terminating Auto Paging", - "All %d notes have been processed and EVERNOTE.PAGING_RESTART_WHEN_COMPLETE is FALSE" % - MetadataProgress.Total, delay=5) - - else: - self.currentPage = MetadataProgress.Page + 1 - restart_title = " > Initiating Auto Paging" - restart_msg = "Page %d completed.
%d notes remain.
%d of %d notes have been processed
" % ( - MetadataProgress.Page, MetadataProgress.Remaining, MetadataProgress.Completed, MetadataProgress.Total) - if self.forceAutoPage or ImportProgress.APICallCount < EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS: - restart = 0 - else: - restart = EVERNOTE.PAGING_TIMER_INTERVAL - suffix = "Delaying Auto Paging: Per EVERNOTE.PAGING_TIMER_INTERVAL, " - - if not self.forceAutoPage: - col.conf[SETTINGS.EVERNOTE_PAGINATION_CURRENT_PAGE] = self.currentPage - col.setMod() - col.save() - - if restart_msg: - if restart > 0: - m, s = divmod(restart, 60) - suffix += "will delay for %d:%02d min before continuing\n" % (m, s) - report_tooltip(restart_title, restart_msg + suffix, delay=5) - if restart > 0: - mw.progress.timer(restart * 1000, lambda: self.proceed(True), False) - return False - else: - return self.proceed(True) def resync_with_local_db(self): evernote_guids = get_all_local_db_guids() @@ -286,4 +264,4 @@ def resync_with_local_db(self): number = self.anki.update_evernote_notes(notes, log_update_if_unchanged=False) tooltip = '%d Entries in Local DB
%d Evernote Notes Created
%d Anki Notes Successfully Updated' % ( len(evernote_guids), local_count, number) - report_tooltip('Resync with Local DB Complete', tooltip) + report_tooltips('Resync with Local DB Complete', tooltip) diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index 49cc866..850c61b 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -13,40 +13,43 @@ ### Anknotes Class Imports from anknotes.AnkiNotePrototype import AnkiNotePrototype -from toc import generateTOCTitle +from anknotes.EvernoteNoteTitle import generateTOCTitle ### Anknotes Main Imports from anknotes.Anki import Anki from anknotes.ankEvernote import Evernote from anknotes.EvernoteNotes import EvernoteNotes from anknotes.EvernoteNotePrototype import EvernoteNotePrototype -from anknotes import settings + +try: from anknotes import settings +except: pass ### Evernote Imports -from evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec, NoteMetadata, NotesMetadataList -from evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote -from evernote.edam.error.ttypes import EDAMSystemException +from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec, NoteMetadata, NotesMetadataList +from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote +from anknotes.evernote.edam.error.ttypes import EDAMSystemException ### Anki Imports -from aqt import mw +try: from aqt import mw +except: pass DEBUG_RAISE_API_ERRORS = False + class EvernoteImporter: - forceAutoPage = False - auto_page_callback = None + forceAutoPage = False + auto_page_callback = None """:type : lambda""" anki = None """:type : Anki""" - evernote = None + evernote = None """:type : Evernote""" updateExistingNotes = UpdateExistingNotes.UpdateNotesInPlace - + def __init(self): self.updateExistingNotes = mw.col.conf.get(SETTINGS.UPDATE_EXISTING_NOTES, UpdateExistingNotes.UpdateNotesInPlace) - def get_evernote_metadata(self): """ :returns: Metadata Progress Instance @@ -60,7 +63,8 @@ def get_evernote_metadata(self): api_action_str = u'trying to search for note metadata' log_api("findNotesMetadata", "[Offset: %d]: Query: '%s'" % (self.MetadataProgress.Offset, query)) try: - result = self.evernote.noteStore.findNotesMetadata(self.evernote.token, evernote_filter, self.MetadataProgress.Offset, + result = self.evernote.noteStore.findNotesMetadata(self.evernote.token, evernote_filter, + self.MetadataProgress.Offset, EVERNOTE.METADATA_QUERY_LIMIT, spec) """ :type: NotesMetadataList @@ -81,9 +85,7 @@ def get_evernote_metadata(self): self.evernote.metadata = self.MetadataProgress.NotesMetadata log(" - Metadata Results: %s" % self.MetadataProgress.Summary, timestamp=False) return True - - def update_in_anki(self, evernote_guids): """ :rtype : EvernoteNoteFetcherResults @@ -96,13 +98,12 @@ def update_in_anki(self, evernote_guids): def import_into_anki(self, evernote_guids): """ :rtype : EvernoteNoteFetcherResults - """ + """ Results = self.evernote.create_evernote_notes(evernote_guids) self.anki.notebook_data = self.evernote.notebook_data Results.Imported = self.anki.add_evernote_notes(Results.Notes) return Results - def check_note_sync_status(self, evernote_guids): """ Check for already existing, up-to-date, local db entries by Evernote GUID @@ -131,9 +132,7 @@ def check_note_sync_status(self, evernote_guids): log(" > USN check for note '%s': %s: db/current/server = %s,%s,%s" % ( evernote_guid, log_info, str(db_usn), str(current_usn), str(server_usn)), 'usn') return notes_already_up_to_date - - - + def proceed(self, auto_paging=False): self.proceed_start(auto_paging) self.proceed_find_metadata(auto_paging) @@ -159,7 +158,7 @@ def proceed_start(self, auto_paging=False): log(" > Note store does not exist. Aborting.") return False self.evernote.getNoteCount = 0 - + def proceed_find_metadata(self, auto_paging=False): global latestEDAMRateLimit, latestSocketError anki_note_ids = self.anki.get_anknotes_note_ids() @@ -168,65 +167,65 @@ def proceed_find_metadata(self, auto_paging=False): self.get_evernote_metadata() if self.MetadataProgress.Status == EvernoteAPIStatus.RateLimitError: m, s = divmod(latestEDAMRateLimit, 60) - report_tooltip(" > Error: Delaying Operation", - "Over the rate limit when searching for Evernote metadata
Evernote requested we wait %d:%02d min" % ( - m, s), delay=5) + report_tooltips(" > Error: Delaying Operation", + "Over the rate limit when searching for Evernote metadata
Evernote requested we wait %d:%02d min" % ( + m, s), delay=5) mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(auto_paging), False) return False elif self.MetadataProgress.Status == EvernoteAPIStatus.SocketError: - report_tooltip(" > Error: Delaying Operation:", - "%s when searching for Evernote metadata
We will try again in 30 seconds" % - latestSocketError['friendly_error_msg'], delay=5) + report_tooltips(" > Error: Delaying Operation:", + "%s when searching for Evernote metadata" % + latestSocketError['friendly_error_msg'], "We will try again in 30 seconds", delay=5) mw.progress.timer(30000, lambda: self.proceed(auto_paging), False) return False self.ImportProgress = EvernoteImportProgress(self.anki, self.MetadataProgress) - self.ImportProgress.loadAlreadyUpdated(self.check_note_sync_status(self.ImportProgress.GUIDs.Server.Existing.All)) - log(" - " + self.ImportProgress.Summary + "\n", timestamp=False) - + self.ImportProgress.loadAlreadyUpdated( + self.check_note_sync_status(self.ImportProgress.GUIDs.Server.Existing.All)) + log(" - " + self.ImportProgress.Summary + "\n", timestamp=False) + def proceed_import_notes(self): - self.anki.start_editing() + self.anki.start_editing() self.ImportProgress.processResults(self.import_into_anki(self.ImportProgress.GUIDs.Server.New)) if self.updateExistingNotes is UpdateExistingNotes.UpdateNotesInPlace: - self.ImportProgress.processUpdateInPlaceResults(self.update_in_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) - elif self.updateExistingNotes is UpdateExistingNotes.DeleteAndUpdate: + self.ImportProgress.processUpdateInPlaceResults( + self.update_in_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) + elif self.updateExistingNotes is UpdateExistingNotes.DeleteAndReAddNotes: self.anki.delete_anki_cards(self.ImportProgress.GUIDs.Server.Existing.OutOfDate) - self.ImportProgress.processDeleteAndUpdateResults(self.import_into_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) - report_tooltip(" > Import Complete", self.ImportProgress.ResultsSummary, prefix='') + self.ImportProgress.processDeleteAndUpdateResults( + self.import_into_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) + report_tooltips(" > Import Complete", self.ImportProgress.ResultsSummaryLines) self.anki.stop_editing() - self.anki.collection().autosave() - + self.anki.collection().autosave() + def proceed_autopage(self): if not self.autoPagingEnabled: - return + return global latestEDAMRateLimit, latestSocketError - col = self.anki.collection() + col = self.anki.collection() status = self.ImportProgress.Status - restart = 0 - restart_msg = "" - restart_title = None - suffix = "" if status == EvernoteAPIStatus.RateLimitError: m, s = divmod(latestEDAMRateLimit, 60) - report_tooltip(" > Error: Delaying Auto Paging", - "Over the rate limit when getting Evernote notes
Evernote requested we wait %d:%02d min" % ( - m, s), delay=5) + report_tooltips(" > Error: Delaying Auto Paging", + "Over the rate limit when getting Evernote notes
Evernote requested we wait %d:%02d min" % ( + m, s), delay=5) mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(True), False) return False if status == EvernoteAPIStatus.SocketError: - report_tooltip(" > Error: Delaying Auto Paging:", - "%s when getting Evernote notes
We will try again in 30 seconds" % latestSocketError[ - 'friendly_error_msg'], delay=5) + report_tooltips(" > Error: Delaying Auto Paging:", + "%s when getting Evernote notes" % latestSocketError[ + 'friendly_error_msg'], + "We will try again in 30 seconds", delay=5) mw.progress.timer(30000, lambda: self.proceed(True), False) return False if self.MetadataProgress.IsFinished: self.currentPage = 1 if self.forceAutoPage: - report_tooltip(" > Terminating Auto Paging", - "All %d notes have been processed and forceAutoPage is True" % self.MetadataProgress.Total, - delay=5) + report_tooltips(" > Terminating Auto Paging", + "All %d notes have been processed and forceAutoPage is True" % self.MetadataProgress.Total, + delay=5) self.auto_page_callback() - return True + return True elif col.conf.get(EVERNOTE.PAGING_RESTART_WHEN_COMPLETE, True): restart = EVERNOTE.PAGING_RESTART_INTERVAL restart_title = " > Restarting Auto Paging" @@ -234,20 +233,22 @@ def proceed_autopage(self): self.MetadataProgress.Total suffix = "Per EVERNOTE.PAGING_RESTART_INTERVAL, " else: - report_tooltip(" > Completed Auto Paging", - "All %d notes have been processed and EVERNOTE.PAGING_RESTART_WHEN_COMPLETE is FALSE" % - self.MetadataProgress.Total, delay=5) - return True - else: # Paging still in progress + report_tooltips(" > Completed Auto Paging", + "All %d notes have been processed and EVERNOTE.PAGING_RESTART_WHEN_COMPLETE is FALSE" % + self.MetadataProgress.Total, delay=5) + return True + else: # Paging still in progress self.currentPage = self.MetadataProgress.Page + 1 restart_title = " > Continuing Auto Paging" restart_msg = "Page %d completed.
%d notes remain.
%d of %d notes have been processed" % ( - self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.Completed, self.MetadataProgress.Total) - restart = 0 + self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.Completed, + self.MetadataProgress.Total) + restart = 0 if self.forceAutoPage: suffix = "
Not delaying as the forceAutoPage flag is set" elif self.ImportProgress.APICallCount < EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS: - suffix = "
Not delaying as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS" % (self.ImportProgress.APICallCount, EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS) + suffix = "
Not delaying as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS" % ( + self.ImportProgress.APICallCount, EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS) else: restart = EVERNOTE.PAGING_TIMER_INTERVAL suffix = "
Delaying Auto Paging: Per EVERNOTE.PAGING_TIMER_INTERVAL, " @@ -256,16 +257,16 @@ def proceed_autopage(self): col.conf[SETTINGS.EVERNOTE_PAGINATION_CURRENT_PAGE] = self.currentPage col.setMod() col.save() - + if restart > 0: m, s = divmod(restart, 60) suffix += "will delay for %d:%02d min before continuing\n" % (m, s) - report_tooltip(restart_title, restart_msg + suffix, delay=5) + report_tooltips(restart_title, (restart_msg + suffix).split('
'), delay=5) if restart > 0: mw.progress.timer(restart * 1000, lambda: self.proceed(True), False) return False - return self.proceed(True) - + return self.proceed(True) + @property def autoPagingEnabled(self): - return (self.anki.collection().conf.get(SETTINGS.EVERNOTE_AUTO_PAGING, True) or self.forceAutoPage) \ No newline at end of file + return self.anki.collection().conf.get(SETTINGS.EVERNOTE_AUTO_PAGING, True) or self.forceAutoPage diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index 60f9288..21ef598 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -11,10 +11,7 @@ class EvernoteNoteFetcher(object): - - - - def __init__(self, evernote, evernote_guid=None, use_local_db_only=False): + def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): """ :type evernote: ankEvernote.Evernote @@ -23,16 +20,18 @@ def __init__(self, evernote, evernote_guid=None, use_local_db_only=False): self.result = EvernoteNoteFetcherResult() self.api_calls = 0 self.keepEvernoteTags = True - self.evernote = evernote self.tagNames = [] + self.tagGuids = [] self.use_local_db_only = use_local_db_only self.__update_sequence_number__ = -1 if not evernote_guid: self.evernote_guid = "" return self.evernote_guid = evernote_guid - if not self.use_local_db_only: - self.__update_sequence_number__ = self.evernote.metadata[self.evernote_guid].updateSequenceNum + if evernote: + self.evernote = evernote + if not self.use_local_db_only: + self.__update_sequence_number__ = self.evernote.metadata[self.evernote_guid].updateSequenceNum self.getNote() def UpdateSequenceNum(self): @@ -42,19 +41,19 @@ def UpdateSequenceNum(self): def reportSuccess(self, note, source=None): self.reportResult(EvernoteAPIStatus.Success, note, source) - - def reportResult(self, status=None, note=None, source=None): + + def reportResult(self, status=None, note=None, source=None): if note: - self.result.Note = note + self.result.Note = note status = EvernoteAPIStatus.Success if not source: source = 2 - if status: + if status: self.result.Status = status if source: - self.result.Source = source - self.results.reportResult(self.result) - + self.result.Source = source + self.results.reportResult(self.result) + def getNoteLocal(self): # Check Anknotes database for note query = "SELECT guid, title, content, notebookGuid, tagNames, updateSequenceNum FROM %s WHERE guid = '%s'" % ( @@ -68,11 +67,18 @@ def getNoteLocal(self): log(" > getNoteLocal: GUID: '%s': %-40s" % (self.evernote_guid, db_note['title']), 'api') assert db_note['guid'] == self.evernote_guid self.reportSuccess(EvernoteNotePrototype(db_note=db_note), 1) - self.tagNames = self.result.Note.TagNames if self.keepEvernoteTags else [] + self.tagNames = self.result.Note.TagNames if self.keepEvernoteTags else [] return True - def addNoteFromServerToDB(self): - # Note that values inserted into the db need to be converted from byte strings (utf-8) to unicode + def addNoteFromServerToDB(self, whole_note=None, tag_names=None): + """ + Adds note to Anknote DB from an Evernote Note object provided by the Evernote API + :type whole_note : evernote.edam.type.ttypes.Note + """ + if whole_note: + self.whole_note = whole_note + if tag_names: + self.tagNames = tag_names title = self.whole_note.title content = self.whole_note.content tag_names = u',' + u','.join(self.tagNames).decode('utf-8') + u',' @@ -85,6 +91,8 @@ def addNoteFromServerToDB(self): title = title.replace(u'\'', u'\'\'') content = content.replace(u'\'', u'\'\'') tag_names = tag_names.replace(u'\'', u'\'\'') + if not self.tagGuids: + self.tagGuids = self.whole_note.tagGuids sql_query_header = u'INSERT OR REPLACE INTO `%s`' % TABLES.EVERNOTE.NOTES sql_query_header_history = u'INSERT INTO `%s`' % TABLES.EVERNOTE.NOTES_HISTORY sql_query_columns = u'(`guid`,`title`,`content`,`updated`,`created`,`updateSequenceNum`,`notebookGuid`,`tagGuids`,`tagNames`) VALUES (\'%s\',\'%s\',\'%s\',%d,%d,%d,\'%s\',\'%s\',\'%s\');' % ( @@ -126,12 +134,16 @@ def getNoteRemote(self): # return None if not self.getNoteRemoteAPICall(): return False self.api_calls += 1 - self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_evernote_guids(self.whole_note.tagGuids) + self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_evernote_guids(self.whole_note.tagGuids) self.addNoteFromServerToDB() if not self.keepEvernoteTags: self.tagNames = [] - self.reportSuccess(EvernoteNotePrototype(whole_note=self.whole_note, tags=self.tagNames)) + self.reportSuccess(EvernoteNotePrototype(whole_note=self.whole_note, tags=self.tagNames)) return True + def setNote(self, whole_note): + self.whole_note = whole_note + self.addNoteFromServerToDB() + def getNote(self, evernote_guid=None): if evernote_guid: self.result.Note = None diff --git a/anknotes/EvernoteNotePrototype.py b/anknotes/EvernoteNotePrototype.py index 1cb9383..8314260 100644 --- a/anknotes/EvernoteNotePrototype.py +++ b/anknotes/EvernoteNotePrototype.py @@ -1,7 +1,8 @@ -from anknotes.EvernoteNoteTitle import EvernoteNoteTitle +from anknotes.EvernoteNoteTitle import EvernoteNoteTitle from anknotes.html import generate_evernote_url, generate_evernote_link, generate_evernote_link_by_level from anknotes.structs import upperFirst + class EvernoteNotePrototype: ################## CLASS Note ################ Title = None @@ -36,7 +37,7 @@ def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid= :type whole_note: evernote.edam.type.ttypes.Note :type db_note: sqlite.Row """ - + self.Status = -1 self.TagNames = tags if whole_note is not None: @@ -122,6 +123,6 @@ def IsBelowLevel(self, level_check): @property def IsLevel(self, level_check): - return self.Title.IsLevel(level_check) - - ################## END CLASS Note ################ \ No newline at end of file + return self.Title.IsLevel(level_check) + + ################## END CLASS Note ################ diff --git a/anknotes/EvernoteNoteTitle.py b/anknotes/EvernoteNoteTitle.py index be60cbd..3c389a2 100644 --- a/anknotes/EvernoteNoteTitle.py +++ b/anknotes/EvernoteNoteTitle.py @@ -1,12 +1,14 @@ ### Anknotes Shared Imports from anknotes.shared import * + def generateTOCTitle(title): title = EvernoteNoteTitle.titleObjectToString(title).upper() for chr in u'?????': title = title.replace(chr.upper(), chr) return title + class EvernoteNoteTitle: level = 0 __title__ = "" @@ -41,23 +43,23 @@ def Depth(self): return self.Level - 1 def Parts(self, level=-1): - return self.Slice(level) - + return self.Slice(level) + def Part(self, level=-1): mySlice = self.Parts(level) if not mySlice: return None return mySlice.Root - + def BaseParts(self, level=None): return self.Slice(1, level) def Parents(self, level=-1): # noinspection PyTypeChecker return self.Slice(None, level) - + def Names(self, level=-1): return self.Parts(level) - + @property def TOCTitle(self): return generateTOCTitle(self.FullTitle) @@ -86,7 +88,7 @@ def Slice(self, start=0, end=None): # print "Slicing: <%s> %s ~ %d,%d" % (type(self.Title), self.Title, start, end) oldParts = self.TitleParts # print "Slicing: %s ~ %d,%d from parts %s" % (self.Title, start, end, str(oldParts)) - assert self.FullTitle and oldParts + assert self.FullTitle and oldParts if start is None and end is None: print "Slicing: %s ~ %d,%d from parts %s" % (self.FullTitle, start, end, str(oldParts)) assert start is not None or end is not None @@ -121,57 +123,71 @@ def IsRoot(self): return self.IsLevel(1) @staticmethod - def titleObjectToString(title): + def titleObjectToString(title, recursion=0): """ :param title: Title in string, unicode, dict, sqlite, TOCKey or NoteTitle formats. Note objects are also parseable :type title: None | str | unicode | dict[str,str] | sqlite.Row | EvernoteNoteTitle :return: string Title :rtype: str """ + # if recursion == 0: + # strr = str_safe(title) + # try: log(u'\n---------------------------------%s' % strr, 'tOTS', timestamp=False) + # except: log(u'\n---------------------------------%s' % '[UNABLE TO DISPLAY TITLE]', 'tOTS', timestamp=False) + # pass + if title is None: - #log('titleObjectToString: NoneType', 'tOTS') + # log('NoneType', 'tOTS', timestamp=False) return "" if isinstance(title, str) or isinstance(title, unicode): - #log('titleObjectToString: str/unicode', 'tOTS') + # log('str/unicode', 'tOTS', timestamp=False) return title - if hasattr(title, 'FullTitle'): - #log('titleObjectToString: FullTitle', 'tOTS') + if hasattr(title, 'FullTitle'): + # log('FullTitle', 'tOTS', timestamp=False) title = title.FullTitle() if callable(title.FullTitle) else title.FullTitle - elif hasattr(title, 'Title'): - #log('titleObjectToString: Title', 'tOTS') + elif hasattr(title, 'Title'): + # log('Title', 'tOTS', timestamp=False) title = title.Title() if callable(title.Title) else title.Title - elif hasattr(title, 'title'): - #log('titleObjectToString: title', 'tOTS') + elif hasattr(title, 'title'): + # log('title', 'tOTS', timestamp=False) title = title.title() if callable(title.title) else title.title else: try: if hasattr(title, 'keys'): keys = title.keys() if callable(title.keys) else title.keys - if 'title' in keys: - #log('titleObjectToString: keys[title]', 'tOTS') + if 'title' in keys: + # log('keys[title]', 'tOTS', timestamp=False) title = title['title'] - elif 'Title' in keys: - #log('titleObjectToString: keys[Title]', 'tOTS') - title = title['Title'] - elif 'title' in title: - #log('titleObjectToString: [title]', 'tOTS') + elif 'Title' in keys: + # log('keys[Title]', 'tOTS', timestamp=False) + title = title['Title'] + elif len(keys) == 0: + # log('keys[empty dict?]', 'tOTS', timestamp=False) + raise + else: + # log('keys[Unknown Attr]: %s' % str(keys), 'tOTS', timestamp=False) + return "" + elif 'title' in title: + # log('[title]', 'tOTS', timestamp=False) title = title['title'] - elif 'Title' in title: - #log('titleObjectToString: [Title]', 'tOTS') - title = title['Title'] + elif 'Title' in title: + # log('[Title]', 'tOTS', timestamp=False) + title = title['Title'] elif FIELDS.TITLE in title: - #log('titleObjectToString: [FIELDS.TITLE]', 'tOTS') - title = title[FIELDS.TITLE] + # log('[FIELDS.TITLE]', 'tOTS', timestamp=False) + title = title[FIELDS.TITLE] else: - #log('titleObjectToString: Nothing Found', 'tOTS') - #log(title) - #log(title.keys()) + # log('Nothing Found', 'tOTS', timestamp=False) + # log(title) + # log(title.keys()) return title - except: - #log('titleObjectToString: except', 'tOTS') - #log(title) - return title - return EvernoteNoteTitle.titleObjectToString(title) + except: + log('except', 'tOTS', timestamp=False) + log(title, 'toTS', timestamp=False) + raise LookupError + recursion += 1 + # log(u'recursing %d: ' % recursion, 'tOTS', timestamp=False) + return EvernoteNoteTitle.titleObjectToString(title, recursion) @property def FullTitle(self): @@ -182,8 +198,9 @@ def __init__(self, titleObj=None): """:type titleObj: str | unicode | sqlite.Row | EvernoteNoteTitle | evernote.edam.type.ttypes.Note | EvernoteNotePrototype.EvernoteNotePrototype """ self.__title__ = self.titleObjectToString(titleObj) + def generateTitleParts(title): - title = EvernoteNoteTitle.titleObjectToString(title) + title = EvernoteNoteTitle.titleObjectToString(title) try: strTitle = re.sub(':+', ':', title) except: @@ -204,4 +221,3 @@ def generateTitleParts(title): raise partsText[i - 1] = txt return partsText - diff --git a/anknotes/EvernoteNotes.py b/anknotes/EvernoteNotes.py index 68b90fa..e90181d 100644 --- a/anknotes/EvernoteNotes.py +++ b/anknotes/EvernoteNotes.py @@ -2,7 +2,7 @@ ### Python Imports from operator import itemgetter -from toc import generateTOCTitle +from anknotes.EvernoteNoteTitle import generateTOCTitle try: from pysqlite2 import dbapi2 as sqlite @@ -15,6 +15,7 @@ from anknotes.EvernoteNotePrototype import EvernoteNotePrototype from anknotes.toc import TOCHierarchyClass + class EvernoteNoteProcessingFlags: delayProcessing = False populateRootTitlesList = True @@ -73,13 +74,13 @@ def __init__(self, delayProcessing=False): self.RootNotes = EvernoteNotesCollection() def addNoteSilently(self, enNote): - """:type enNote: EvernoteNote.EvernoteNote""" + """:type enNote: EvernoteNote.EvernoteNote""" assert enNote self.Notes[enNote.Guid] = enNote def addNote(self, enNote): """:type enNote: EvernoteNote.EvernoteNote""" - assert enNote + assert enNote self.addNoteSilently(enNote) if self.processingFlags.delayProcessing: return self.processNote(enNote) @@ -91,7 +92,7 @@ def addDBNote(self, dbNote): log(dbNote) log(dbNote.keys) log(dir(dbNote)) - assert enNote + assert enNote self.addNote(enNote) def addDBNotes(self, dbNotes): @@ -131,7 +132,6 @@ def getNoteFromDBByGuid(self, guid): def processNote(self, enNote): """:type enNote: EvernoteNote.EvernoteNote""" if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict or self.processingFlags.populateMissingRootTitlesList or self.processingFlags.populateMissingRootTitlesDict: - if enNote.IsChild: # log([enNote.Title, enNote.Level, enNote.Title.TitleParts, enNote.IsChild]) rootTitle = enNote.Title.Root @@ -156,7 +156,7 @@ def processNote(self, enNote): if not rootTitleStr in self.RootNotes.TitlesList: self.RootNotes.TitlesList.append(rootTitleStr) if self.processingFlags.populateRootTitlesDict: - self.RootNotes.TitlesDict[rootTitleStr][enNote.Guid] = enNote.Title.Base() + self.RootNotes.TitlesDict[rootTitleStr][enNote.Guid] = enNote.Title.Base self.RootNotes.NotesDict[rootTitleStr][enNote.Guid] = enNote if self.processingFlags.populateChildRootTitles or self.processingFlags.populateExistingRootTitlesList or self.processingFlags.populateExistingRootTitlesDict: if enNote.IsRoot: @@ -177,7 +177,7 @@ def processNote(self, enNote): if child_count is 1: self.RootNotesChildren.TitlesDict[rootGuid] = {} self.RootNotesChildren.NotesDict[rootGuid] = {} - childBaseTitle = childEnNote.Title.Base() + childBaseTitle = childEnNote.Title.Base self.RootNotesChildren.TitlesDict[rootGuid][childGuid] = childBaseTitle self.RootNotesChildren.NotesDict[rootGuid][childGuid] = childEnNote @@ -230,11 +230,28 @@ def getRootNotes(self): query += " AND tagNames NOT LIKE '%%,%s,%%'" % EVERNOTE.TAG.OUTLINE self.addDbQuery(query, 'title ASC') + def populateAllPotentialRootNotes(self): + self.RootNotesMissing = EvernoteNotesCollection() + processingFlags = EvernoteNoteProcessingFlags(False) + processingFlags.populateMissingRootTitlesList = True + processingFlags.populateMissingRootTitlesDict = True + self.processingFlags = processingFlags + + log(" CHECKING FOR ALL POTENTIAL ROOT TITLES ", 'RootTitles-TOC', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-TOC', timestamp=False) + log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) + self.getChildNotes() + log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-TOC', + timestamp=False) + self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) + + return self.processAllRootNotesMissing() + def populateAllNonCustomRootNotes(self): return self.populateAllRootNotesMissing(True, True) def populateAllRootNotesMissing(self, ignoreAutoTOCAsRootTitle=False, ignoreOutlineAsRootTitle=False): - processingFlags = EvernoteNoteProcessingFlags(False) processingFlags.populateMissingRootTitlesList = True processingFlags.populateMissingRootTitlesDict = True @@ -263,7 +280,7 @@ def populateAllRootNotesMissing(self, ignoreAutoTOCAsRootTitle=False, ignoreOutl def processAllRootNotesMissing(self): """:rtype : list[EvernoteTOCEntry]""" - DEBUG_HTML = True + DEBUG_HTML = False count = 0 count_isolated = 0 # log (" CREATING TOC's " , 'tocList', clear=True, timestamp=False) @@ -271,6 +288,7 @@ def processAllRootNotesMissing(self): # if DEBUG_HTML: log('

CREATING TOCs

', 'extra\\logs\\anknotes-toc-ols\\toc-index.htm', timestamp=False, clear=True, extension='htm') ols = [] dbRows = [] + returns = [] """:type : list[EvernoteTOCEntry]""" ankDB().execute("DELETE FROM %s WHERE 1 " % TABLES.EVERNOTE.AUTO_TOC) # olsz = None @@ -295,7 +313,7 @@ def processAllRootNotesMissing(self): timestamp=False) else: count += 1 - log(" [%-3d] %s %s" % (count, rootTitleStr, '(O)' if outline else ' '), 'RootTitles-Missing', + log(" [%-3d] %s %s" % (count, rootTitleStr, '(O)' if outline else ' '), 'RootTitles-TOC', timestamp=False) # tocList = TOCList(rootTitleStr) tocHierarchy = TOCHierarchyClass(rootTitleStr) @@ -318,7 +336,7 @@ def processAllRootNotesMissing(self): # childName = enChildNote.Title.Name # childTitle = enChildNote.Title.FullTitle log(" %2d: %d. --> %-60s" % (count_child, level, childBaseTitle), - 'RootTitles-Missing', timestamp=False) + 'RootTitles-TOC', timestamp=False) # tocList.generateEntry(childTitle, enChildNote) tocHierarchy.addNote(enChildNote) realTitle = ankDB().scalar( @@ -333,29 +351,37 @@ def processAllRootNotesMissing(self): # file_object.close() ol = tocHierarchy.GetOrderedList() - dbRows.append(EvernoteTOCEntry(realTitle, ol, ',' + ','.join(tags) + ',', notebookGuid)) + tocEntry = EvernoteTOCEntry(realTitle, ol, ',' + ','.join(tags) + ',', notebookGuid) + returns.append(tocEntry) + dbRows.append(tocEntry.items()) # ol = realTitleUTF8 # if olsz is None: olsz = ol # olsz += ol # ol = '
    \r\n%s
\r\n' - olutf8 = ol.encode('utf8') - ols.append(olutf8) + # strr = tocHierarchy.__str__() - fn = 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_') + '.htm' - full_path = os.path.join(ANKNOTES.FOLDER_LOGS, fn) - if not os.path.exists(os.path.dirname(full_path)): - os.mkdir(os.path.dirname(full_path)) - file_object = open(full_path, 'w') - file_object.write(olutf8) - file_object.close() - - if DEBUG_HTML: log(ol, 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_'), timestamp=False, clear=True, extension='htm') - # log("Created TOC #%d:\n%s\n\n" % (count, strr), 'tocList', timestamp=False) + if DEBUG_HTML: + ols.append(ol) + olutf8 = ol.encode('utf8') + fn = 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_') + '.htm' + full_path = os.path.join(ANKNOTES.FOLDER_LOGS, fn) + if not os.path.exists(os.path.dirname(full_path)): + os.mkdir(os.path.dirname(full_path)) + file_object = open(full_path, 'w') + file_object.write(olutf8) + file_object.close() + + # if DEBUG_HTML: log(ol, 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_'), timestamp=False, clear=True, extension='htm') + # log("Created TOC #%d:\n%s\n\n" % (count, strr), 'tocList', timestamp=False) if DEBUG_HTML: ols_html = u'\r\n




\r\n'.join(ols) - fn = 'extra\\logs\\anknotes-toc-ols\\toc-index.htm' - file_object = open(fn, 'w') - file_object.write('

CREATING TOCs

\n\n' + ols_html) + fn = 'anknotes-toc-ols\\toc-index.htm' + file_object = open(os.path.join(ANKNOTES.FOLDER_LOGS, fn), 'w') + try: file_object.write(u'

CREATING TOCs

\n\n' + ols_html) + except: + try: file_object.write(u'

CREATING TOCs

\n\n' + ols_html.encode('utf-8')) + except: pass + file_object.close() # print dbRows @@ -364,7 +390,7 @@ def processAllRootNotesMissing(self): dbRows) ankDB().commit() - return dbRows + return returns def populateAllRootNotesWithoutTOCOrOutlineDesignation(self): processingFlags = EvernoteNoteProcessingFlags() diff --git a/anknotes/__main__.py b/anknotes/__main__.py index b3b7000..6ce58a6 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -42,6 +42,8 @@ def import_timer_toggle(): def anknotes_profile_loaded(): log("Profile Loaded", "load") menu.anknotes_load_menu_settings() + if ANKNOTES.ENABLE_VALIDATION and ANKNOTES.AUTOMATE_VALIDATION: + menu.upload_validated_notes(True) import_timer_toggle() ''' For testing purposes only: @@ -51,6 +53,7 @@ def anknotes_profile_loaded(): # menu.see_also() # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) menu.see_also(3) + pass def anknotes_onload(): diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 4ad76ce..711fa74 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -3,7 +3,13 @@ import socket import stopwatch from StringIO import StringIO -from lxml import etree + +try: + from lxml import etree + + eTreeImported = True +except: + eTreeImported = False try: from pysqlite2 import dbapi2 as sqlite except ImportError: @@ -13,15 +19,19 @@ from anknotes.shared import * from anknotes.error import * -### Anknotes Class Imports -from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher +if not eTreeImported: + ### Anknotes Class Imports + from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher + + ### Evernote Imports + from anknotes.evernote.edam.type.ttypes import Note as EvernoteNote + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException + from anknotes.evernote.api.client import EvernoteClient -### Evernote Imports -from evernote.edam.type.ttypes import Note as EvernoteNote -from evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException -from evernote.api.client import EvernoteClient + try: + from aqt.utils import openLink, getText, showInfo + except: pass -from aqt.utils import openLink, getText ### Anki Imports # import anki @@ -42,14 +52,19 @@ class Evernote(object): tag_data = {} """:type : dict[str, anknotes.structs.EvernoteTag]""" DTD = None + hasValidator = None def __init__(self): - auth_token = mw.col.conf.get(SETTINGS.EVERNOTE_AUTH_TOKEN, False) - self.keepEvernoteTags = mw.col.conf.get(SETTINGS.KEEP_EVERNOTE_TAGS, SETTINGS.KEEP_EVERNOTE_TAGS_DEFAULT_VALUE) + global eTreeImported, dbLocal self.tag_data = {} self.notebook_data = {} self.noteStore = None self.getNoteCount = 0 + self.hasValidator = eTreeImported + if ankDBIsLocal(): + return + self.keepEvernoteTags = mw.col.conf.get(SETTINGS.KEEP_EVERNOTE_TAGS, SETTINGS.KEEP_EVERNOTE_TAGS_DEFAULT_VALUE) + auth_token = mw.col.conf.get(SETTINGS.EVERNOTE_AUTH_TOKEN, False) if not auth_token: # First run of the Plugin we did not save the access key yet secrets = {'holycrepe': '36f46ea5dec83d4a', 'scriptkiddi-2682': '965f1873e4df583c'} @@ -90,33 +105,32 @@ def initialize_note_store(self): raise return 0 - def validateNoteBody(self, noteBody,title="Note Body"): + def validateNoteBody(self, noteBody, title="Note Body"): timerFull = stopwatch.Timer() timerInterval = stopwatch.Timer(False) - if not self.DTD: timerInterval.reset() - log("Loading ENML DTD", "lxml") + log("Loading ENML DTD", "lxml", timestamp=False, do_print=True) self.DTD = etree.DTD(ANKNOTES.ENML_DTD) - log("DTD Loaded in %s" % str(timerInterval), "lxml") + log("DTD Loaded in %s" % str(timerInterval), "lxml", timestamp=False, do_print=True) timerInterval.stop() timerInterval.reset() - log("Loading XML for %s" % title, "lxml") + log("Loading XML for %s" % title, "lxml", timestamp=False, do_print=False) try: - tree = etree.parse(noteBody) + tree = etree.parse(StringIO(noteBody)) except Exception as e: timer_header = ' at %s. The whole process took %s' % (str(timerInterval), str(timerFull)) log_str = "XML Loading of %s failed.%s\n - Error Details: %s" log_str_error = log_str % (title, '', str(e)) log_str = log_str % (title, timer_header, str(e)) - log(log_str, "lxml") + log(log_str, "lxml", timestamp=False, do_print=True) log_error(log_str_error) - return False - log("XML Loaded in %s" % str(timerInterval), "lxml") + return False, log_str_error + log("XML Loaded in %s for %s" % (str(timerInterval), title), "lxml", timestamp=False, do_print=False) # timerInterval.stop() timerInterval.reset() - log("Validating %s with ENML DTD" % title, "lxml") + log("Validating %s with ENML DTD" % title, "lxml", timestamp=False, do_print=False) try: success = self.DTD.validate(tree) except Exception as e: @@ -124,19 +138,24 @@ def validateNoteBody(self, noteBody,title="Note Body"): log_str = "DTD Validation of %s failed.%s\n - Error Details: %s" log_str_error = log_str % (title, '', str(e)) log_str = log_str % (title, timer_header, str(e)) - log(log_str, "lxml") + log(log_str, "lxml", timestamp=False, do_print=True) log_error(log_str_error) - return False - log("Validation %s in %s. Entire process took %s" % ("Succeeded" if success else "Failed", str(timerInterval), str(timerFull)), "lxml") + return False, log_str_error + log("Validation %s in %s. Entire process took %s" % ( + "Succeeded" if success else "Failed", str(timerInterval), str(timerFull)), "lxml", timestamp=False, + do_print=False) + if not success: + print "Validation %-9s for %s" % ("Succeeded" if success else "Failed", title) + errors = self.DTD.error_log.filter_from_errors() if not success: - log_str = "DTD Validation Errors for %s: \n%s\n" % (title, self.DTD.error_log.filter_from_errors()) + log_str = "DTD Validation Errors for %s: \n%s\n" % (title, errors) log(log_str) log_error(log_str) timerInterval.stop() timerFull.stop() del timerInterval del timerFull - return success + return success, errors def validateNoteContent(self, content, title="Note Contents"): """ @@ -176,7 +195,25 @@ def makeNoteBody(content, resources=[], encode=True): nBody = nBody.encode('utf-8') return nBody - def makeNote(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=[], guid=None): + def addNoteToMakeNoteQueue(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=[], + guid=None): + sql = "SELECT validation_status FROM %s WHERE " % TABLES.MAKE_NOTE_QUEUE + if guid: + sql += "guid = '%s'" % guid + else: + sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) + status = ankDB().execute(sql) + if status is 1: + return EvernoteAPIStatus.Success + # log_sql(sql) + # log_sql([ guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook]) + ankDB().execute( + "INSERT INTO %s(guid, title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?, ?)" % TABLES.MAKE_NOTE_QUEUE, + guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook) + return EvernoteAPIStatus.RequestQueued + + def makeNote(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=[], guid=None, + validated=None): """ Create or Update a Note instance with title and body Send Note object to user's account @@ -187,6 +224,15 @@ def makeNote(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None """ callType = "create" + if validated is None: + if not ANKNOTES.ENABLE_VALIDATION: + validated = True + else: + validation_status = self.addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames, parentNotebook, + resources, guid) + if not validation_status.IsSuccess and not self.hasValidator: + return validation_status, None + ourNote = EvernoteNote() ourNote.title = noteTitle.encode('utf-8') if guid: @@ -195,19 +241,26 @@ def makeNote(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None ## Build body of note nBody = self.makeNoteBody(noteContents, resources) - if not self.validateNoteBody(nBody, ourNote.title): - return EvernoteAPIStatus.UserError, None + if not validated is True and not validation_status.IsSuccess: + success, errors = self.validateNoteBody(nBody, ourNote.title) + if not success: + return EvernoteAPIStatus.UserError, None ourNote.content = nBody - if '' in tagNames: tagNames.remove('') + self.initialize_note_store() + + while '' in tagNames: tagNames.remove('') if len(tagNames) > 0: - if ANKNOTES.EVERNOTE_IS_SANDBOXED: + if ANKNOTES.EVERNOTE_IS_SANDBOXED and not '#Sandbox' in tagNames: tagNames.append("#Sandbox") ourNote.tagNames = tagNames ## parentNotebook is optional; if omitted, default notebook is used - if parentNotebook and hasattr(parentNotebook, 'guid'): - ourNote.notebookGuid = parentNotebook.guid + if parentNotebook: + if hasattr(parentNotebook, 'guid'): + ourNote.notebookGuid = parentNotebook.guid + elif isinstance(parentNotebook, str) or isinstance(parentNotebook, unicode): + ourNote.notebookGuid = parentNotebook ## Attempt to create note in Evernote account @@ -276,13 +329,13 @@ def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): fetcher = EvernoteNoteFetcher(self, use_local_db_only=use_local_db_only) if len(evernote_guids) == 0: fetcher.results.Status = EvernoteAPIStatus.EmptyRequest - return fetcher.results + return fetcher.results fetcher.keepEvernoteTags = self.keepEvernoteTags for evernote_guid in self.evernote_guids: self.evernote_guid = evernote_guid if not fetcher.getNote(evernote_guid): return fetcher.results - return fetcher.results + return fetcher.results def check_ancillary_data_up_to_date(self): if not self.check_tags_up_to_date(): @@ -389,6 +442,3 @@ def get_tag_names_from_evernote_guids(self, tag_guids_original): DEBUG_RAISE_API_ERRORS = False - -testEN = Evernote() -testEN.validateNoteBody("Test") \ No newline at end of file diff --git a/anknotes/constants.py b/anknotes/constants.py index 8ec3c8b..f66c574 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- import os + PATH = os.path.dirname(os.path.abspath(__file__)) + class ANKNOTES: FOLDER_EXTRA = os.path.join(PATH, 'extra') FOLDER_ANCILLARY = os.path.join(FOLDER_EXTRA, 'ancillary') @@ -12,6 +14,7 @@ class ANKNOTES: TEMPLATE_FRONT = os.path.join(FOLDER_ANCILLARY, 'FrontTemplate.htm') CSS = u'_AviAnkiCSS.css' ENML_DTD = os.path.join(FOLDER_ANCILLARY, 'enml2.dtd') + VALIDATION_SCRIPT = os.path.join(os.path.dirname(PATH), 'test.py') # anknotes-standAlone.py') ICON_EVERNOTE_WEB = os.path.join(FOLDER_GRAPHICS, u'evernote_web.ico') IMAGE_EVERNOTE_WEB = ICON_EVERNOTE_WEB.replace('.ico', '.png') ICON_EVERNOTE_ARTCORE = os.path.join(FOLDER_GRAPHICS, u'evernote_artcore.ico') @@ -21,8 +24,11 @@ class ANKNOTES: DATE_FORMAT = '%Y-%m-%d %H:%M:%S' DEVELOPER_MODE = (os.path.isfile(os.path.join(FOLDER_TESTING, 'anknotes.developer'))) DEVELOPER_MODE_AUTOMATE = (os.path.isfile(os.path.join(FOLDER_TESTING, 'anknotes.developer.automate'))) - UPLOAD_AUTO_TOC_NOTES = False # Set false if debugging note creation - AUTO_TOC_NOTES_MAX = 5 # Set to -1 for unlimited + UPLOAD_AUTO_TOC_NOTES = True # Set False if debugging note creation + AUTO_TOC_NOTES_MAX = -1 # Set to -1 for unlimited + ENABLE_VALIDATION = True + AUTOMATE_VALIDATION = True + ROOT_TITLES_BASE_QUERY = "notebookGuid != 'fdccbccf-ee70-4069-a587-82772a96d9d3'" class MODELS: @@ -84,6 +90,7 @@ class TAG: class TABLES: SEE_ALSO = "anknotes_see_also" + MAKE_NOTE_QUEUE = "anknotes_make_note_queue" class EVERNOTE: NOTEBOOKS = "anknotes_evernote_notebooks" diff --git a/anknotes/db.py b/anknotes/db.py index b0e655d..56bbf44 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -6,7 +6,7 @@ from anknotes.constants import * try: - from aqt import mw + from aqt import mw except: pass @@ -19,6 +19,11 @@ def ankDBSetLocal(): dbLocal = True +def ankDBIsLocal(): + global dbLocal + return dbLocal + + def ankDB(): global ankNotesDBInstance, dbLocal if not ankNotesDBInstance: @@ -172,5 +177,7 @@ def Init(self): """CREATE TABLE IF NOT EXISTS `%s` ( `id` INTEGER, `source_evernote_guid` TEXT NOT NULL, `number` INTEGER NOT NULL DEFAULT 100, `uid` INTEGER NOT NULL DEFAULT -1, `shard` TEXT NOT NULL DEFAULT -1, `target_evernote_guid` TEXT NOT NULL, `html` TEXT NOT NULL, `title` TEXT NOT NULL, `from_toc` INTEGER DEFAULT 0, `is_toc` INTEGER DEFAULT 0, `is_outline` INTEGER DEFAULT 0, PRIMARY KEY(id) );""" % TABLES.SEE_ALSO) self.execute( """CREATE TABLE IF NOT EXISTS `%s` ( `root_title` TEXT NOT NULL UNIQUE, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL, `notebookGuid` TEXT NOT NULL, PRIMARY KEY(root_title) );""" % TABLES.EVERNOTE.AUTO_TOC) + self.execute( + """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT, `title` TEXT NOT NULL, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL DEFAULT ',,', `notebookGuid` TEXT, `validation_status` INTEGER NOT NULL DEFAULT 0, `validation_result` TEXT);""" % TABLES.MAKE_NOTE_QUEUE) self.InitTags() self.InitNotebooks() diff --git a/anknotes/error.py b/anknotes/error.py index c008ad3..daa69f5 100644 --- a/anknotes/error.py +++ b/anknotes/error.py @@ -17,9 +17,9 @@ def HandleSocketError(e, strErrorBase): global latestSocketError errorcode = e[0] friendly_error_msgs = { - errno.ECONNREFUSED: "Connection was refused", + errno.ECONNREFUSED: "Connection was refused", errno.WSAECONNRESET: "Connection was reset or forcibly closed by the remote host", - errno.ETIMEDOUT: "Connection timed out" + errno.ETIMEDOUT: "Connection timed out" } error_constant = errno.errorcode[errorcode] if errorcode in friendly_error_msgs: diff --git a/anknotes/find_deleted_notes.py b/anknotes/find_deleted_notes.py index 9f6a718..f782dea 100644 --- a/anknotes/find_deleted_notes.py +++ b/anknotes/find_deleted_notes.py @@ -17,7 +17,6 @@ all_notes = ankDB().execute("SELECT guid, title FROM %s " % TABLES.EVERNOTE.NOTES) find_guids = {} - for line in all_notes: # line = line.split('::: ') # guid = line[0] diff --git a/anknotes/graphics.py b/anknotes/graphics.py index 579cc61..c71f236 100644 --- a/anknotes/graphics.py +++ b/anknotes/graphics.py @@ -1,7 +1,7 @@ from anknotes.constants import * ### Anki Imports try: - from aqt.qt import QIcon, QPixmap + from aqt.qt import QIcon, QPixmap except: pass diff --git a/anknotes/html.py b/anknotes/html.py index 7225fbc..34a1650 100644 --- a/anknotes/html.py +++ b/anknotes/html.py @@ -25,7 +25,10 @@ def strip_tags(html): def strip_tags_and_new_lines(html): return strip_tags(html).replace('\r\n', ' ').replace('\r', ' ').replace('\n', ' ') + __text_escape_phrases__ = u'&|&|\'|'|"|"|>|>|<|<'.split('|') + + def escape_text(title): global __text_escape_phrases__ for i in range(0, len(__text_escape_phrases__), 2): @@ -40,6 +43,7 @@ def unescape_text(title): title = title.replace(" ", " ") return title + def clean_title(title): title = unescape_text(title) if isinstance(title, str): @@ -47,6 +51,7 @@ def clean_title(title): title = title.replace(u'\xa0', ' ') return title + def generate_evernote_url(guid): ids = get_evernote_account_ids() return u'evernote:///view/%s/%s/%s/%s/' % (ids.uid, ids.shard, guid, guid) @@ -107,69 +112,69 @@ def generate_evernote_span(title=None, element_type=None, value=None, guid=None, evernote_link_colors = { 'Levels': { - 'OL': { + 'OL': { 1: { 'Default': 'rgb(106, 0, 129);', - 'Hover': 'rgb(168, 0, 204);' + 'Hover': 'rgb(168, 0, 204);' }, 2: { 'Default': 'rgb(235, 0, 115);', - 'Hover': 'rgb(255, 94, 174);' + 'Hover': 'rgb(255, 94, 174);' }, 3: { 'Default': 'rgb(186, 0, 255);', - 'Hover': 'rgb(213, 100, 255);' + 'Hover': 'rgb(213, 100, 255);' }, 4: { 'Default': 'rgb(129, 182, 255);', - 'Hover': 'rgb(36, 130, 255);' + 'Hover': 'rgb(36, 130, 255);' }, 5: { 'Default': 'rgb(232, 153, 220);', - 'Hover': 'rgb(142, 32, 125);' + 'Hover': 'rgb(142, 32, 125);' }, 6: { 'Default': 'rgb(201, 213, 172);', - 'Hover': 'rgb(130, 153, 77);' + 'Hover': 'rgb(130, 153, 77);' }, 7: { 'Default': 'rgb(231, 179, 154);', - 'Hover': 'rgb(215, 129, 87);' + 'Hover': 'rgb(215, 129, 87);' }, 8: { 'Default': 'rgb(249, 136, 198);', - 'Hover': 'rgb(215, 11, 123);' + 'Hover': 'rgb(215, 11, 123);' } }, - 'Headers': { + 'Headers': { 'Auto TOC': 'rgb(11, 59, 225);' }, 'Modifiers': { - 'Orange': 'rgb(222, 87, 0);', - 'Orange (Light)': 'rgb(250, 122, 0);', - 'Dark Red/Pink': 'rgb(164, 15, 45);', + 'Orange': 'rgb(222, 87, 0);', + 'Orange (Light)': 'rgb(250, 122, 0);', + 'Dark Red/Pink': 'rgb(164, 15, 45);', 'Pink Alternative LVL1:': 'rgb(188, 0, 88);' } }, 'Titles': { 'Field Title Prompt': 'rgb(169, 0, 48);' }, - 'Links': { + 'Links': { 'See Also': { 'Default': 'rgb(45, 79, 201);', - 'Hover': 'rgb(108, 132, 217);' + 'Hover': 'rgb(108, 132, 217);' }, - 'TOC': { + 'TOC': { 'Default': 'rgb(173, 0, 0);', - 'Hover': 'rgb(196, 71, 71);' + 'Hover': 'rgb(196, 71, 71);' }, - 'Outline': { + 'Outline': { 'Default': 'rgb(105, 170, 53);', - 'Hover': 'rgb(135, 187, 93);' + 'Hover': 'rgb(135, 187, 93);' }, 'AnkNotes': { 'Default': 'rgb(30, 155, 67);', - 'Hover': 'rgb(107, 226, 143);' + 'Hover': 'rgb(107, 226, 143);' } } } diff --git a/anknotes/logging.py b/anknotes/logging.py index dac011d..3d2969f 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -10,9 +10,9 @@ ### Anki Imports try: - from aqt import mw - from aqt.utils import tooltip - from aqt.qt import QMessageBox, QPushButton + from aqt import mw + from aqt.utils import tooltip + from aqt.qt import QMessageBox, QPushButton except: pass @@ -38,22 +38,16 @@ def show_tooltip(text, time_out=3000, delay=None): tooltip(text, time_out) -def report_tooltip(log_title, log_text="", delay=None, prefix='- '): - str_tip = log_text - if not str_tip: - str_tip = log_title - - show_tooltip(str_tip, delay=delay) - - if log_title: - log_title += ": " - delimit = "-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------" - if log_text: - log_text = delimit + "
%s\n" % log_text - log(log_title) - log_text = log_text.replace('

', '
').replace('
', '\n ' + prefix ) - log(log_text, timestamp=False, replace_newline=True) - +def report_tooltips(title, header, log_lines=[], delay=None): + lines = [] + for line in header.split('
') + log_lines.join('
').split('
'): + while line[0] is '-': line = '\t' + line[1:] + lines.append('- ' + line) + if len(lines) > 1: lines[0] += ': ' + log_text = '
'.join(lines) + show_tooltip(log_text, delay=delay) + log(title, replace_newline=False) + log(" " + "-" * 192 + '\n' + log_text.replace('
', '\n'), timestamp=False, replace_newline=True) def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0): global imgEvernoteWebMsgBox, icoEvernoteArtcore @@ -108,7 +102,7 @@ def obj2log_simple(content): def log(content='', filename='', prefix='', clear=False, timestamp=True, extension='log', blank=False, - replace_newline=None): + replace_newline=None, do_print=False): if blank: filename = content content = '' @@ -144,6 +138,8 @@ def log(content='', filename='', prefix='', clear=False, timestamp=True, extensi os.mkdir(os.path.dirname(full_path)) with open(full_path, 'w+' if clear else 'a+') as fileLog: print>> fileLog, prefix + ' ' + st + content + if do_print: + print prefix + ' ' + st + content log("Log Loaded", "load") @@ -153,8 +149,8 @@ def log_sql(value): log(value, 'sql') -def log_error(value): - log(value, '+error') +def log_error(value, crossPost=True): + log(value, '+' if crossPost else '' + 'error') def print_dump(obj): diff --git a/anknotes/menu.py b/anknotes/menu.py index 122656b..4f8edfd 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- ### Python Imports +from subprocess import * + try: from pysqlite2 import dbapi2 as sqlite except ImportError: @@ -26,15 +28,25 @@ def anknotes_setup_menu(): [ ["&Import from Evernote", import_from_evernote], ["&Enable Auto Import On Profile Load", {'action': anknotes_menu_auto_import_changed, 'checkable': True}], + ["Note &Validation", + [ + ["&Validate Pending Notes", validate_pending_notes], + ["&Upload Validated Notes", upload_validated_notes] + ] + ], ["Process &See Also Links [Power Users Only!]", [ ["Complete All &Steps", see_also], ["SEPARATOR", None], ["Step &1: Process Notes Without See Also Field", lambda: see_also(1)], ["Step &2: Extract Links from TOC", lambda: see_also(2)], - ["Step &3: Create Auto TOC", lambda: see_also(3)], - ["Step &4: Insert Links Into See Also Field", lambda: see_also(4)], - ["Step &5: Insert TOC and Outlines Into Notes", lambda: see_also(5)] + ["Step &3: Create Auto TOC Evernote Notes", lambda: see_also(3)], + ["Step &4: Validate and Upload Auto TOC Notes", lambda: see_also(4)], + ["SEPARATOR", None], + ["Step &5: Insert TOC/Outline Links Into Evernote Notes", lambda: see_also(5)], + ["Step &6: Validate and Upload Modified Notes", lambda: see_also(6)], + ["SEPARATOR", None], + ["Step &7: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(7)] ] ], ["Res&ync with Local DB", resync_with_local_db], @@ -98,10 +110,41 @@ def import_from_evernote(auto_page_callback=None): controller.proceed() +def upload_validated_notes(automated=False): + controller = Controller() + controller.upload_validated_notes(automated) + + +def validate_pending_notes(showAlerts=True, uploadAfterValidation=True): + if showAlerts: + showInfo("""Press Okay to save and close your Anki collection, open the command-line note validation tool, and then re-open your Anki collection.%s + + Anki will be unresponsive until the validation tool completes. This will take at least 45 seconds. + + The tool's output will be shown. If it is truncated, you may view the full log in the anknotes addon folder at extra\\logs\\anknotes-MakeNoteQueue-*.log""" \ + % 'Any validated notes will be automatically uploaded once your Anki collection is reopened.\n\n' if uploadAfterValidation else '') + mw.col.close() + # mw.closeAllCollectionWindows() + handle = Popen(ANKNOTES.VALIDATION_SCRIPT, stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) + stdoutdata, stderrdata = handle.communicate() + info = "" + if stderrdata: + info += "ERROR: {%s}\n\n" % stderrdata + info += "Return data: \n%s" % stdoutdata + + if showAlerts: + showInfo("Completed: %s" % info[:500]) + + mw.col.reopen() + if uploadAfterValidation: + upload_validated_notes() + + def see_also(steps=None): controller = Controller() if not steps: steps = range(1, 10) if isinstance(steps, int): steps = [steps] + showAlerts = (len(steps) == 1) if 1 in steps: # Should be unnecessary once See Also algorithms are finalized log(" > See Also: Step 1: Processing Un Added See Also Notes") @@ -110,13 +153,19 @@ def see_also(steps=None): log(" > See Also: Step 2: Extracting Links from TOC") controller.anki.extract_links_from_toc() if 3 in steps: - log(" > See Also: Step 3: Creating Auto TOC") + log(" > See Also: Step 3: Creating Auto TOC Evernote Notes") controller.create_auto_toc() if 4 in steps: - log(" > See Also: Step 4: Inserting TOC/Outline Links Into See Also Field") - controller.anki.insert_toc_into_see_also() + log(" > See Also: Step 4: Validate and Upload Auto TOC Notes") + validate_pending_notes(showAlerts) if 5 in steps: - log(" > See Also: Step 5: Inserting TOC/Outline Contents Into Respective Fields") + log(" > See Also: Step 5: Inserting TOC/Outline Links Into Evernote Notes") + controller.anki.insert_toc_into_see_also() + if 6 in steps: + log(" > See Also: Step 6: Validate and Upload Modified Notes") + validate_pending_notes(showAlerts) + if 7 in steps: + log(" > See Also: Step 7: Inserting TOC/Outline Contents Into Anki Notes") controller.anki.insert_toc_and_outline_contents_into_notes() diff --git a/anknotes/settings.py b/anknotes/settings.py index 2e7c6d7..46c0128 100644 --- a/anknotes/settings.py +++ b/anknotes/settings.py @@ -6,15 +6,15 @@ ### Anki Imports try: - import anki - import aqt - from aqt.preferences import Preferences - from aqt.utils import getText, openLink, getOnlyText - from aqt.qt import QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QGroupBox, SIGNAL, QCheckBox, \ + import anki + import aqt + from aqt.preferences import Preferences + from aqt.utils import getText, openLink, getOnlyText + from aqt.qt import QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QGroupBox, SIGNAL, QCheckBox, \ QComboBox, QSpacerItem, QSizePolicy, QWidget, QSpinBox, QFormLayout, QGridLayout, QFrame, QPalette, \ QRect, QStackedLayout, QDateEdit, QDateTimeEdit, QTimeEdit, QDate, QDateTime, QTime, QPushButton, QIcon, \ QMessageBox, QPixmap - from aqt import mw + from aqt import mw except: pass diff --git a/anknotes/shared.py b/anknotes/shared.py index 167a045..ccfbb0f 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -17,7 +17,8 @@ from aqt import mw from aqt.qt import QIcon, QPixmap, QPushButton, QMessageBox from aqt.utils import tooltip - from evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, EDAMNotFoundException + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ + EDAMNotFoundException except: pass diff --git a/anknotes/stopwatch/__init__.py b/anknotes/stopwatch/__init__.py index f1446fc..eb0584d 100644 --- a/anknotes/stopwatch/__init__.py +++ b/anknotes/stopwatch/__init__.py @@ -37,9 +37,11 @@ def mult(a, b): __version__ = '0.3.1' __author__ = 'John Paulett ' + class Timer(object): __times__ = [] __stopped = None + def __init__(self, begin=True): if begin: self.reset() @@ -66,21 +68,24 @@ def elapsed(self): of seconds from the instance creation until stop() was called. """ return self.__last_time() - self.__start + elapsed = property(elapsed) - + def start_time(self): """The time at which the Timer instance was created. """ return self.__start + start_time = property(start_time) - + def stop_time(self): """The time at which stop() was called, or None if stop was never called. """ - return self.__stopped - stop_time = property(stop_time) - + return self.__stopped + + stop_time = property(stop_time) + def __last_time(self): """Return the current time or the time at which stop() was call, if called at all. @@ -88,24 +93,27 @@ def __last_time(self): if self.__stopped is not None: return self.__stopped return self.__time() - + def __time(self): """Wrapper for time.time() to allow unit testing. """ return time.time() - + def __str__(self): """Nicely format the elapsed time """ - if self.elapsed < 60: - return str(self.elapsed) + ' sec' - m, s = divmod(self.elapsed, 60) + total_seconds = int(round(self.elapsed)) + if total_seconds < 60: + return str(total_seconds) + ' sec' + m, s = divmod(total_seconds, 60) return '%dm %dsec' % (m, s) + def clockit(func): """Function decorator that times the evaluation of *func* and prints the execution time. """ + def new(*args, **kw): t = Timer() retval = func(*args, **kw) @@ -113,4 +121,5 @@ def new(*args, **kw): log('Function %s completed in %s' % (func.__name__, t), "clockit") del t return retval + return new diff --git a/anknotes/stopwatch/tests/__init__.py b/anknotes/stopwatch/tests/__init__.py index 9c74fa6..2c810c8 100644 --- a/anknotes/stopwatch/tests/__init__.py +++ b/anknotes/stopwatch/tests/__init__.py @@ -11,56 +11,61 @@ import stopwatch from stopwatch import clockit + class TimeControlledTimer(stopwatch.Timer): def __init__(self): self.__count = 0 super(TimeControlledTimer, self).__init__() - + def __time(self): retval = self.__count self.__count += 1 - return retval + return retval class TimerTestCase(unittest.TestCase): def setUp(self): self.timer = stopwatch.Timer() - + def test_simple(self): point1 = self.timer.elapsed self.assertTrue(point1 > 0) - + point2 = self.timer.elapsed self.assertTrue(point2 > point1) - + point3 = self.timer.elapsed self.assertTrue(point3 > point2) - + def test_stop(self): point1 = self.timer.elapsed self.assertTrue(point1 > 0) - + self.timer.stop() point2 = self.timer.elapsed self.assertTrue(point2 > point1) - + point3 = self.timer.elapsed self.assertEqual(point2, point3) + @clockit def timed_multiply(a, b): return a * b - + + class DecoratorTestCase(unittest.TestCase): def test_clockit(self): self.assertEqual(6, timed_multiply(2, b=3)) - + + def suite(): - suite = unittest.TestSuite() + suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(TimerTestCase)) suite.addTest(unittest.makeSuite(DecoratorTestCase)) - #suite.addTest(doctest.DocTestSuite(stopwatch)) + # suite.addTest(doctest.DocTestSuite(stopwatch)) return suite + if __name__ == '__main__': - unittest.main(defaultTest='suite') \ No newline at end of file + unittest.main(defaultTest='suite') diff --git a/anknotes/structs.py b/anknotes/structs.py index 8abd2e1..048abdc 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -1,11 +1,14 @@ from anknotes.db import * -from enum import Enum +from anknotes.enum import Enum +from anknotes.logging import log, str_safe + # from evernote.edam.notestore.ttypes import NoteMetadata, NotesMetadataList def upperFirst(name): return name[0].upper() + name[1:] + class EvernoteStruct(object): success = False Name = "" @@ -17,18 +20,20 @@ class EvernoteStruct(object): def keys(self): if len(self.__attr_order__) == 0: - self.__attr_order__ = [].extend(self.__sql_columns__).append(self.__sql_where__) + self.__attr_order__ = self.__sql_columns__ + self.__attr_order__.append(self.__sql_where__) return self.__attr_order__ def items(self): lst = [] for key in self.__attr_order__: - lst.append(getattr(self, key)) + lst.append(getattr(self, upperFirst(key))) return lst def getFromDB(self): - query = "SELECT %s FROM %s WHERE %s = '%s'" % (', '.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, - getattr(self, upperFirst(self.__sql_where__))) + query = "SELECT %s FROM %s WHERE %s = '%s'" % ( + ', '.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, + getattr(self, upperFirst(self.__sql_where__))) result = ankDB().first(query) if result: self.success = True @@ -42,27 +47,44 @@ def getFromDB(self): def __init__(self, *args, **kwargs): if isinstance(self.__sql_columns__, str): self.__sql_columns__ = [self.__sql_columns__] + if isinstance(self.__attr_order__, str) or isinstance(self.__attr_order__, unicode): + self.__attr_order__ = self.__attr_order__.replace('|', ' ').split(' ') + args = list(args) + if len(args) > 0: + val = args[0] + if isinstance(val, sqlite.Row): + del args[0] + for key in val.keys(): + value = val[key] + kwargs[key] = value i = 0 - for v in args: - k = self.__attr_order__[i] - setattr(self, upperFirst(k), v) + for value in args: + key = self.__attr_order__[i] + if key in self.__attr_order__: + setattr(self, upperFirst(key), value) + else: + log("Unable to set attr #%d for %s to %s" % (i, self.__class__.__name__, str_safe(value)), 'error') i += 1 - for v in [].extend(self.__sql_columns__).append(self.__sql_where__): - if v == "fetch_" + self.__sql_where__: - setattr(self, upperFirst(self.__sql_where__), kwargs[v]) + lst = set().union(self.__sql_columns__, [self.__sql_where__], self.__attr_order__) + for key in kwargs: + if key == "fetch_" + self.__sql_where__: + setattr(self, upperFirst(self.__sql_where__), kwargs[key]) self.getFromDB() - elif v in kwargs: setattr(self, upperFirst(v), kwargs[v]) + elif key in lst: setattr(self, upperFirst(key), kwargs[key]) + class EvernoteNotebook(EvernoteStruct): Stack = "" __sql_columns__ = ["name", "stack"] __sql_table__ = TABLES.EVERNOTE.NOTEBOOKS + class EvernoteTag(EvernoteStruct): ParentGuid = "" __sql_columns__ = ["name", "parentGuid"] __sql_table__ = TABLES.EVERNOTE.TAGS + class EvernoteTOCEntry(EvernoteStruct): RealTitle = "" """:type : str""" @@ -74,10 +96,34 @@ class EvernoteTOCEntry(EvernoteStruct): TagNames = "" """:type : str""" NotebookGuid = "" - __attr_order__ = ['realTitle' 'orderedList', 'tagNames', 'notebookGuid'] + + def __init__(self, *args, **kwargs): + self.__attr_order__ = 'realTitle|orderedList|tagNames|notebookGuid' + super(self.__class__, self).__init__(*args, **kwargs) + + +class EvernoteValidationEntry(EvernoteStruct): + Guid = "" + """:type : str""" + Title = "" + """:type : str""" + Contents = "" + """:type : str""" + TagNames = "" + """:type : str""" + NotebookGuid = "" + + def __init__(self, *args, **kwargs): + # spr = super(self.__class__ , self) + # spr.__attr_order__ = self.__attr_order__ + # spr.__init__(*args, **kwargs) + self.__attr_order__ = 'guid|title|contents|tagNames|notebookGuid' + super(self.__class__, self).__init__(*args, **kwargs) + class EvernoteAPIStatus(Enum): - Uninitialized, EmptyRequest, Success, RateLimitError, SocketError, UserError, NotFoundError, UnhandledError, Unknown = range(-2, 7) + Uninitialized, EmptyRequest, RequestQueued, Success, RateLimitError, SocketError, UserError, NotFoundError, UnhandledError, Unknown = range( + -3, 7) # Uninitialized = -100 # NoParameters = -1 # Success = 0 @@ -87,22 +133,25 @@ class EvernoteAPIStatus(Enum): # NotFoundError = 4 # UnhandledError = 5 # Unknown = 100 - - @property + + @property def IsError(self): return (self != EvernoteAPIStatus.Unknown and self.value > EvernoteAPIStatus.Success.value) - + @property def IsSuccessful(self): - return (self == EvernoteAPIStatus.Success or self == EvernoteAPIStatus.EmptyRequest) - + return ( + self == EvernoteAPIStatus.Success or self == EvernoteAPIStatus.EmptyRequest or self == EvernoteAPIStatus.RequestQueued) + @property def IsSuccess(self): return (self == EvernoteAPIStatus.Success) + class EvernoteImportType: Add, UpdateInPlace, DeleteAndUpdate = range(3) + class EvernoteNoteFetcherResult(object): def __init__(self, note=None, status=None, source=-1): """ @@ -115,127 +164,137 @@ def __init__(self, note=None, status=None, source=-1): self.Status = status self.Source = source + class EvernoteNoteFetcherResults(object): - Status = EvernoteAPIStatus.Uninitialized - ImportType = EvernoteImportType.Add - Local = 0 - Notes = [] - Imported = 0 - Max = 0 - AlreadyUpToDate = 0 - - @property - def DownloadSuccess(self): - return self.Count == self.Max - - @property - def AnkiSuccess(self): - return self.Imported == self.Count - - @property - def TotalSuccess(self): - return self.DownloadSuccess and self.AnkiSuccess - - @property - def LocalDownloadsOccurred(self): - return self.Local > 0 - - @property - def Remote(self): - return self.Count - self.Local - - @property - def SummaryShort(self): - add_update_strs = ['New', "Added"] if self.ImportType == EvernoteImportType.Add else ['Existing', 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated'] - return "%d %s Notes Have Been %s" % (self.Imported, add_update_strs[0], add_update_strs[1]) - - @property - def SummaryLines(self): - add_update_strs = ['New', "Added to"] if self.ImportType == EvernoteImportType.Add else ['Existing', "%s in" % ('Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated')] - add_update_strs[1] += " Anki" - if self.Max is 0: return [] - ## Evernote Status - if self.DownloadSuccess: - line = "All %d" % self.Max - else: - line = "%d of %d" % (self.Count, self.Max) - lines=[line + " %s Evernote Metadata Results Were Successfully Downloaded%s." % (add_update_strs[0], (' And %s' % add_update_strs[1]) if self.AnkiSuccess else '')] - if self.Status.IsError: - lines.append("An error occurred during download (%s)." % str(self.Status)) - if self.DownloadSuccess: - return lines - if self.AnkiSuccess: - line = "All %d" % self.Imported - else: - line = "%d of %d" % (self.Imported, self.Count) - lines.append(line + " %s Downloaded Evernote Notes Have Been Successfully %s." % (add_update_strs[0], add_update_strs[1])) - - - if self.LocalDownloadsOccurred: - tooltip += "
--- %d %s note(s) were unexpectedly found in the local db and did not require an API call." % (self.Local, add_update_strs[0]) - tooltip += "
--- %d %s note(s) required an API call" % (self.Remote, add_update_strs[0]) - - if not self.ImportType == EvernoteImportType.Add: - tooltip += "
%d existing note(s) are already up-to-date with Evernote's servers, so they were not retrieved." % n3 - + Status = EvernoteAPIStatus.Uninitialized + ImportType = EvernoteImportType.Add + Local = 0 + Notes = [] + Imported = 0 + Max = 0 + AlreadyUpToDate = 0 + + @property + def DownloadSuccess(self): + return self.Count == self.Max + + @property + def AnkiSuccess(self): + return self.Imported == self.Count + + @property + def TotalSuccess(self): + return self.DownloadSuccess and self.AnkiSuccess + + @property + def LocalDownloadsOccurred(self): + return self.Local > 0 + + @property + def Remote(self): + return self.Count - self.Local + + @property + def SummaryShort(self): + add_update_strs = ['New', "Added"] if self.ImportType == EvernoteImportType.Add else ['Existing', + 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated'] + return "%d %s Notes Have Been %s" % (self.Imported, add_update_strs[0], add_update_strs[1]) + + @property + def SummaryLines(self): + add_update_strs = ['New', "Added to"] if self.ImportType == EvernoteImportType.Add else ['Existing', + "%s in" % ( + 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated')] + add_update_strs[1] += " Anki" + if self.Max is 0: return [] + ## Evernote Status + if self.DownloadSuccess: + line = "All %d" % self.Max + else: + line = "%d of %d" % (self.Count, self.Max) + lines = [line + " %s Evernote Metadata Results Were Successfully Downloaded%s." % ( + add_update_strs[0], (' And %s' % add_update_strs[1]) if self.AnkiSuccess else '')] + if self.Status.IsError: + lines.append("An error occurred during download (%s)." % str(self.Status)) + if self.DownloadSuccess: return lines - - @property - def Summary(self): - lines = self.SummaryLines - if len(lines) is 0: - return '' - return '
- '.join(lines) - - @property - def Count(self): - return len(self.Notes) - - @property - def EvernoteFails(self): - return self.Max - self.Count - - @property - def AnkiFails(self): - return self.Count - self.Imported - - def __init__(self, status=None, local=None): - """ - :param status: - :type status : EvernoteAPIStatus - :param local: - :return: - """ - if not status: status = EvernoteAPIStatus.Uninitialized - if not local: local = 0 - self.Status = status - self.Local = local - self.Imported = 0 - self.Notes = [] - - def reportResult(self, result): - """ - :type result : EvernoteNoteFetcherResult - """ - self.Status = result.Status - if self.Status == EvernoteAPIStatus.Success: - self.Notes.append(result.Note) - if self.Source == 1: - self.Local += 1 + if self.AnkiSuccess: + line = "All %d" % self.Imported + else: + line = "%d of %d" % (self.Imported, self.Count) + lines.append(line + " %s Downloaded Evernote Notes Have Been Successfully %s." % ( + add_update_strs[0], add_update_strs[1])) + + if self.LocalDownloadsOccurred: + lines.append( + "-%d %s note(s) were unexpectedly found in the local db and did not require an API call." % self.Local) + lines.append("-%d %s note(s) required an API call" % self.Remote) + + if not self.ImportType == EvernoteImportType.Add and self.AlreadyUpToDate > 0: + lines.append( + "%d existing note(s) are already up-to-date with Evernote's servers, so they were not retrieved." % self.AlreadyUpToDate) + + return lines + + @property + def Summary(self): + lines = self.SummaryLines + if len(lines) is 0: + return '' + return '
- '.join(lines) + + @property + def Count(self): + return len(self.Notes) + + @property + def EvernoteFails(self): + return self.Max - self.Count + + @property + def AnkiFails(self): + return self.Count - self.Imported + + def __init__(self, status=None, local=None): + """ + :param status: + :type status : EvernoteAPIStatus + :param local: + :return: + """ + if not status: status = EvernoteAPIStatus.Uninitialized + if not local: local = 0 + self.Status = status + self.Local = local + self.Imported = 0 + self.Notes = [] + + def reportResult(self, result): + """ + :type result : EvernoteNoteFetcherResult + """ + self.Status = result.Status + if self.Status == EvernoteAPIStatus.Success: + self.Notes.append(result.Note) + if self.Source == 1: + self.Local += 1 class EvernoteImportProgress: Anki = None """:type : anknotes.Anki.Anki""" + class _GUIDs: Local = None - class Server: - All = None + + class Server: + All = None New = None + class Existing: - All = None + All = None UpToDate = None - OutOfDate = None + OutOfDate = None def loadNew(self, server_evernote_guids=None): if server_evernote_guids: @@ -249,11 +308,11 @@ def loadNew(self, server_evernote_guids=None): class Results: Adding = None """:type : EvernoteNoteFetcherResults""" - Updating = None - """:type : EvernoteNoteFetcherResults""" - + Updating = None + """:type : EvernoteNoteFetcherResults""" + GUIDs = _GUIDs() - + @property def Adding(self): return len(self.GUIDs.Server.New) @@ -273,48 +332,49 @@ def Success(self): @property def IsError(self): return self.Status.IsError - - @property + + @property def Status(self): - s1 = self.Results.Adding.Status + s1 = self.Results.Adding.Status s2 = self.Results.Updating.Status if self.Results.Updating else EvernoteAPIStatus.Uninitialized if s1 == EvernoteAPIStatus.RateLimitError or s2 == EvernoteAPIStatus.RateLimitError: return EvernoteAPIStatus.RateLimitError if s1 == EvernoteAPIStatus.SocketError or s2 == EvernoteAPIStatus.SocketError: return EvernoteAPIStatus.SocketError if s1.IsError: - return s1 + return s1 if s2.IsError: - return s2 + return s2 if s1.IsSuccessful and s2.IsSuccessful: return EvernoteAPIStatus.Success if s2 == EvernoteAPIStatus.Uninitialized: - return s1 + return s1 if s1 == EvernoteAPIStatus.Success: return s2 - return s1 - + return s1 + @property def Summary(self): lst = [ "New Notes (%d)" % self.Adding, "Existing Out-Of-Date Notes (%d)" % self.Updating, "Existing Up-To-Date Notes (%d)" % self.AlreadyUpToDate - ] + ] return ' > '.join(lst) def loadAlreadyUpdated(self, db_guids): self.GUIDs.Server.Existing.UpToDate = db_guids - self.GUIDs.Server.Existing.OutOfDate = set(self.GUIDs.Server.Existing.All) - set(self.GUIDs.Server.Existing.UpToDate) - + self.GUIDs.Server.Existing.OutOfDate = set(self.GUIDs.Server.Existing.All) - set( + self.GUIDs.Server.Existing.UpToDate) + def processUpdateInPlaceResults(self, results): return self.processResults(results, EvernoteImportType.UpdateInPlace) - + def processDeleteAndUpdateResults(self, results): return self.processResults(results, EvernoteImportType.DeleteAndUpdate) - - @property + + @property def ResultsSummaryShort(self): line = self.Results.Adding.SummaryShort if self.Results.Adding.Status.IsError: @@ -323,45 +383,37 @@ def ResultsSummaryShort(self): line += " to Anki. Updating is disabled" else: line += " and " + self.Results.Updating.SummaryShort - return line - + return line + @property - def ResultsSummary(self): - delimiter = "
-" - delimiter2 = "
-" - summary = '- ' + self.ResultsSummaryShort - lines = self.Results.Adding.SummaryLines - if len(lines)>0: - summary += delimiter + delimiter2.join(lines) + def ResultsSummaryLines(self): + lines = [self.ResultsSummaryShort] + self.Results.Adding.SummaryLines if self.Results.Updating: - lines = self.Results.Updating.SummaryLines - if len(lines)>0: - summary += delimiter + delimiter2.join(lines) - return summary - - @property + lines += self.Results.Updating.SummaryLines + return lines + + @property def APICallCount(self): return self.Results.Adding.Remote + self.Results.Updating.Remote if self.Results.Updating else 0 - - def processResults(self, results, importType = None): + + def processResults(self, results, importType=None): """ :type results : EvernoteNoteFetcherResults :type importType : EvernoteImportType """ if not importType: importType = EvernoteImportType.Add - results.ImportType = importType + results.ImportType = importType if importType == EvernoteImportType.Add: results.Max = self.Adding results.AlreadyUpToDate = 0 - self.Results.Adding = results + self.Results.Adding = results else: results.Max = self.Updating results.AlreadyUpToDate = self.AlreadyUpToDate - self.Results.Updating = results - + self.Results.Updating = results - def setup(self,anki_note_ids=None): + def setup(self, anki_note_ids=None): if not anki_note_ids: anki_note_ids = self.Anki.get_anknotes_note_ids() self.GUIDs.Local = self.Anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) @@ -384,6 +436,7 @@ def __init__(self, anki=None, metadataProgress=None, server_evernote_guids=None, self.Results.Adding = EvernoteNoteFetcherResults() self.Results.Updating = EvernoteNoteFetcherResults() + class EvernoteMetadataProgress: Page = 1 Total = -1 @@ -394,7 +447,7 @@ class EvernoteMetadataProgress: NotesMetadata = {} """ :type: dict[str, anknotes.evernote.edam.notestore.ttypes.NoteMetadata] - """ + """ @property def IsFinished(self): @@ -413,10 +466,10 @@ def ListPadded(self): lst = [] for val in self.List: pad = 20 - len(val) - padl = int(round(pad/2)) + padl = int(round(pad / 2)) padr = padl if padl + padr > pad: padr -= 1 - val = ' '*padl + val + ' '*padr + val = ' ' * padl + val + ' ' * padr lst.append(val) return lst @@ -444,7 +497,7 @@ def loadResults(self, result): :param result: Result Returned by Evernote API Call to getNoteMetadata :type result: anknotes.evernote.edam.notestore.ttypes.NotesMetadataList :return: - """ + """ self.Total = int(result.totalNotes) self.Current = len(result.notes) self.UpdateCount = result.updateCount @@ -454,4 +507,4 @@ def loadResults(self, result): for note in result.notes: # assert isinstance(note, NoteMetadata) self.Guids.append(note.guid) - self.NotesMetadata[note.guid] = note \ No newline at end of file + self.NotesMetadata[note.guid] = note diff --git a/anknotes/toc.py b/anknotes/toc.py index 7cac587..7cd9bfc 100644 --- a/anknotes/toc.py +++ b/anknotes/toc.py @@ -1,8 +1,14 @@ # -*- coding: utf-8 -*- +try: + from pysqlite2 import dbapi2 as sqlite +except ImportError: + from sqlite3 import dbapi2 as sqlite from anknotes.constants import * from anknotes.html import generate_evernote_link, generate_evernote_span from anknotes.logging import log_dump -from anknotes.EvernoteNoteTitle import EvernoteNoteTitle +from anknotes.EvernoteNoteTitle import EvernoteNoteTitle, generateTOCTitle +from anknotes.EvernoteNotePrototype import EvernoteNotePrototype + def TOCNamePriority(title): for index, value in enumerate( @@ -44,7 +50,7 @@ class TOCHierarchyClass: Title = None """:type : EvernoteNoteTitle""" Note = None - """:type : EvernoteNote.EvernoteNote""" + """:type : EvernoteNotePrototype.EvernoteNotePrototype""" Outline = None """:type : TOCHierarchyClass""" Number = 1 @@ -127,9 +133,10 @@ def addHierarchy(self, tocHierarchy): selfLevel = self.Title.Level tocTestBase = tocHierarchy.Title.FullTitle.replace(self.Title.FullTitle, '') if tocTestBase[:2] == ': ': - tocTestBase = tocTestBase[2:] + tocTestBase = tocTestBase[2:] - print " \nAdd Hierarchy: %-70s --> %-40s\n-------------------------------------" % (self.Title.FullTitle, tocTestBase) + print " \nAdd Hierarchy: %-70s --> %-40s\n-------------------------------------" % ( + self.Title.FullTitle, tocTestBase) if selfLevel > tocHierarchy.Title.Level: print "New Title Level is Below current level" @@ -141,7 +148,7 @@ def addHierarchy(self, tocHierarchy): if tocSelfSibling.TOCTitle != selfTOCTitle: print "New Title doesn't match current path" return False - + if tocNewLevel is self.Title.Level: if tocHierarchy.IsOutline: tocHierarchy.Parent = self @@ -151,40 +158,48 @@ def addHierarchy(self, tocHierarchy): print "New Title Level is current level, but New Title is not Outline" return False - - tocNewSelfChild = tocNewTitle.Parents(self.Title.Level+1) + tocNewSelfChild = tocNewTitle.Parents(self.Title.Level + 1) tocNewSelfChildTOCName = tocNewSelfChild.TOCName isDirectChild = (tocHierarchy.Level == self.Level + 1) if isDirectChild: tocNewChildNamesTitle = "N/A" print "New Title is a direct child of the current title" else: - tocNewChildNamesTitle = tocHierarchy.Title.Names(self.Title.Level+1).FullTitle + tocNewChildNamesTitle = tocHierarchy.Title.Names(self.Title.Level + 1).FullTitle print "New Title is a Grandchild or deeper of the current title " for tocChild in self.Children: - assert(isinstance(tocChild, TOCHierarchyClass)) + assert (isinstance(tocChild, TOCHierarchyClass)) if tocChild.Title.TOCName == tocNewSelfChildTOCName: - print "%-60s Child %-20s Match Succeeded for %s." % (self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + print "%-60s Child %-20s Match Succeeded for %s." % ( + self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) success = tocChild.addHierarchy(tocHierarchy) if success: return True - print "%-60s Child %-20s Match Succeeded for %s: However, unable to add to matched child" % (self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) - print "%-60s Child %-20s Search failed for %s" % (self.Title.FullTitle + ':', tocNewSelfChild.Name, tocNewChildNamesTitle) + print "%-60s Child %-20s Match Succeeded for %s: However, unable to add to matched child" % ( + self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + print "%-60s Child %-20s Search failed for %s" % ( + self.Title.FullTitle + ':', tocNewSelfChild.Name, tocNewChildNamesTitle) newChild = tocHierarchy if isDirectChild else TOCHierarchyClass(tocNewSelfChild) newChild.parent = self if isDirectChild: - print "%-60s Child %-20s Created Direct Child for %s." % (self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + print "%-60s Child %-20s Created Direct Child for %s." % ( + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) success = True else: - print "%-60s Child %-20s Created Title-Only Child for %-40ss." % (self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + print "%-60s Child %-20s Created Title-Only Child for %-40ss." % ( + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) success = newChild.addHierarchy(tocHierarchy) - print "%-60s Child %-20s Created Title-Only Child for %-40s: Match %s." % (self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle, "succeeded" if success else "failed") + print "%-60s Child %-20s Created Title-Only Child for %-40s: Match %s." % ( + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle, + "succeeded" if success else "failed") self.__isSorted__ = False self.Children.append(newChild) - print "%-60s Child %-20s Appended Child for %s. Operation was an overall %s." % (self.Title.FullTitle + ':', newChild.Title.Name + ':', tocNewChildNamesTitle, "success" if success else "failure") + print "%-60s Child %-20s Appended Child for %s. Operation was an overall %s." % ( + self.Title.FullTitle + ':', newChild.Title.Name + ':', tocNewChildNamesTitle, + "success" if success else "failure") return success def sortChildren(self): @@ -219,16 +234,16 @@ def __str__(self, fullTitle=True, fullChildrenTitles=False): return '\n'.join(lst) def GetOrderedListItem(self, title=None): - if not title: title = self.Title.FullTitle + if not title: title = self.Title.Name selfTitleStr = title selfLevel = self.Title.Level selfDepth = self.Title.Depth if selfLevel == 1: guid = 'guid-pending' - if self.Note: guid = self.Note.guid + if self.Note: guid = self.Note.Guid link = generate_evernote_link(guid, generateTOCTitle(selfTitleStr), 'TOC') if self.Outline: - link += ' ' + generate_evernote_link(self.Outline.note.guid, + link += ' ' + generate_evernote_link(self.Outline.Note.Guid, '(O)', 'Outline', escape=False) return link @@ -295,6 +310,9 @@ def __init__(self, title=None, note=None, number=1): assert note or title self.Outline = None if note: + if (isinstance(note, sqlite.Row)): + note = EvernoteNotePrototype(db_note=note) + self.Note = note self.Title = EvernoteNoteTitle(note) else: @@ -306,12 +324,12 @@ def __init__(self, title=None, note=None, number=1): -# -# tocTest = TOCHierarchyClass("My Root Title") -# tocTest.addTitle("My Root Title: Somebody") -# tocTest.addTitle("My Root Title: Somebody: Else") -# tocTest.addTitle("My Root Title: Someone") -# tocTest.addTitle("My Root Title: Someone: Else") -# tocTest.addTitle("My Root Title: Someone: Else: Entirely") -# tocTest.addTitle("My Root Title: Z This: HasNo: Direct Parent") -# pass \ No newline at end of file + # + # tocTest = TOCHierarchyClass("My Root Title") + # tocTest.addTitle("My Root Title: Somebody") + # tocTest.addTitle("My Root Title: Somebody: Else") + # tocTest.addTitle("My Root Title: Someone") + # tocTest.addTitle("My Root Title: Someone: Else") + # tocTest.addTitle("My Root Title: Someone: Else: Entirely") + # tocTest.addTitle("My Root Title: Z This: HasNo: Direct Parent") + # pass diff --git a/anknotes_standAlone.py b/anknotes_standAlone.py new file mode 100644 index 0000000..059be9d --- /dev/null +++ b/anknotes_standAlone.py @@ -0,0 +1,121 @@ +import os +from anknotes import stopwatch +import time +try: + from lxml import etree + eTreeImported=True +except: + eTreeImported=False +if eTreeImported: + + try: + from pysqlite2 import dbapi2 as sqlite + except ImportError: + from sqlite3 import dbapi2 as sqlite + + ### Anknotes Module Imports for Stand Alone Scripts + from anknotes import evernote as evernote + + ### Anknotes Shared Imports + from anknotes.shared import * + from anknotes.error import * + from anknotes.toc import TOCHierarchyClass + + ### Anknotes Class Imports + from anknotes.AnkiNotePrototype import AnkiNotePrototype + from anknotes.EvernoteNoteTitle import generateTOCTitle + + ### Anknotes Main Imports + from anknotes.Anki import Anki + from anknotes.ankEvernote import Evernote + # from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher + # from anknotes.EvernoteNotes import EvernoteNotes + # from anknotes.EvernoteNotePrototype import EvernoteNotePrototype + # from anknotes.EvernoteImporter import EvernoteImporter + # + # ### Evernote Imports + # from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec + # from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException + # from anknotes.evernote.api.client import EvernoteClient + + + ankDBSetLocal() + db = ankDB() + db.Init() + + failed_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) + pending_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 0" % TABLES.MAKE_NOTE_QUEUE) + success_queued_items = db.all("SELECT * FROM %s WHERE validation_status = -1 " % TABLES.MAKE_NOTE_QUEUE) + + currentLog = 'Successful' + log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d SUCCESSFUL MAKE NOTE QUEUE ITEMS " % len(success_queued_items), 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) + + for result in success_queued_items: + line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' + line += result['title'] + log(line, 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=False) + + + currentLog = 'Failed' + log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d FAILED MAKE NOTE QUEUE ITEMS " % len(failed_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) + + + for result in failed_queued_items: + line = '%-60s ' % (result['title'] + ':') + line += (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW" + line += result['validation_result'] + log(line, 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) + log("------------------------------------------------\n", 'MakeNoteQueue-'+currentLog, timestamp=False) + log(result['contents'], 'MakeNoteQueue-'+currentLog, timestamp=False) + log("------------------------------------------------\n", 'MakeNoteQueue-'+currentLog, timestamp=False) + + EN = Evernote() + + currentLog = 'Pending' + log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d PENDING MAKE NOTE QUEUE ITEMS " % len(pending_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) + + timerFull = stopwatch.Timer() + for result in pending_queued_items: + guid = result['guid'] + noteContents = result['contents'] + noteTitle = result['title'] + line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' + + + + + success, errors = EN.validateNoteContent(noteContents, noteTitle) + validation_status = 1 if success else -1 + + line = " SUCCESS! " if success else " FAILURE: " + line += ' ' if result['guid'] else ' NEW ' + # line += ' %-60s ' % (result['title'] + ':') + if not success: + errors = '\n * ' + '\n * '.join(errors) + log(line, 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) + else: + errors = '\n'.join(errors) + + + sql = "UPDATE %s SET validation_status = %d, validation_result = '%s' WHERE " % (TABLES.MAKE_NOTE_QUEUE, validation_status, escape_text_sql(errors)) + if guid: + sql += "guid = '%s'" % guid + else: + sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) + + db.execute(sql) + + + timerFull.stop() + log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) + + + db.commit() + db.close() \ No newline at end of file diff --git a/test.py b/test.py new file mode 100644 index 0000000..7254a98 --- /dev/null +++ b/test.py @@ -0,0 +1,3 @@ + +import anknotes_standAlone + From 6aa778855e5967c9cffe01d455d77ffbe1b49236 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli Date: Wed, 16 Sep 2015 13:37:49 -0400 Subject: [PATCH 03/70] Minor changes to logging --- .gitignore | 1 + anknotes/extra/ancillary/regex.txt | 6 +- anknotes/logging.py | 28 +++++--- anknotes/menu.py | 12 ++-- anknotes/setup.cfg | 3 + anknotes_standAlone_template.py | 112 +++++++++++++++++++++++++++++ setup.cfg | 3 + 7 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 anknotes/setup.cfg create mode 100644 anknotes_standAlone_template.py create mode 100644 setup.cfg diff --git a/.gitignore b/.gitignore index cb64557..e5f293c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ anknotes/extra/powergrep/ anknotes/extra/local/ anknotes/extra/testing/ anknotes/extra/anki_master +autopep8 noteTest.py *.bk diff --git a/anknotes/extra/ancillary/regex.txt b/anknotes/extra/ancillary/regex.txt index 87b8692..56f2df2 100644 --- a/anknotes/extra/ancillary/regex.txt +++ b/anknotes/extra/ancillary/regex.txt @@ -27,4 +27,8 @@ Step 6: Process "See Also: " Links )?(?: - )?)(?.+))(?
) \ No newline at end of file + )?)(?.+))(?
) + +Replace Python Parameters with Reference to Self + ([\w_]+)=[.+?](,|\) + $1=$1$2 \ No newline at end of file diff --git a/anknotes/logging.py b/anknotes/logging.py index 3d2969f..f39204e 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -1,14 +1,14 @@ -### Python Imports +# Python Imports from datetime import datetime, timedelta import difflib import pprint import re -### Anknotes Shared Imports +# Anknotes Shared Imports from anknotes.constants import * from anknotes.graphics import * -### Anki Imports +# Anki Imports try: from aqt import mw from aqt.utils import tooltip @@ -46,8 +46,11 @@ def report_tooltips(title, header, log_lines=[], delay=None): if len(lines) > 1: lines[0] += ': ' log_text = '
'.join(lines) show_tooltip(log_text, delay=delay) - log(title, replace_newline=False) + log_blank() + log(title) log(" " + "-" * 192 + '\n' + log_text.replace('
', '\n'), timestamp=False, replace_newline=True) + log_blank() + def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0): global imgEvernoteWebMsgBox, icoEvernoteArtcore @@ -101,12 +104,19 @@ def obj2log_simple(content): return content -def log(content='', filename='', prefix='', clear=False, timestamp=True, extension='log', blank=False, +def log_blank(filename='', clear=False, extension='log'): + log(timestamp=False, filename=filename, clear=clear, extension=extension) + + +def log_plain(content=None, filename='', prefix='', clear=False, extension='log', + replace_newline=None, do_print=False): + log(timestamp=False, content=content, filename=filename, prefix=prefix, clear=clear, extension=extension, + replace_newline=replace_newline, do_print=do_print) + + +def log(content=None, filename='', prefix='', clear=False, timestamp=True, extension='log', replace_newline=None, do_print=False): - if blank: - filename = content - content = '' - timestamp = False + if content is None: content = '' else: content = obj2log_simple(content) if len(content) == 0: content = '{EMPTY STRING}' diff --git a/anknotes/menu.py b/anknotes/menu.py index 4f8edfd..5a3f39c 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -### Python Imports +# Python Imports from subprocess import * try: @@ -7,14 +7,14 @@ except ImportError: from sqlite3 import dbapi2 as sqlite -### Anknotes Shared Imports +# Anknotes Shared Imports from anknotes.shared import * from anknotes.constants import * -### Anknotes Main Imports +# Anknotes Main Imports from anknotes.Controller import Controller -### Anki Imports +# Anki Imports from aqt.qt import SIGNAL, QMenu, QAction from aqt import mw @@ -121,7 +121,7 @@ def validate_pending_notes(showAlerts=True, uploadAfterValidation=True): Anki will be unresponsive until the validation tool completes. This will take at least 45 seconds. - The tool's output will be shown. If it is truncated, you may view the full log in the anknotes addon folder at extra\\logs\\anknotes-MakeNoteQueue-*.log""" \ + The tool's output will be shown. If it is truncated, you may view the full log in the anknotes addon folder at extra\\logs\\anknotes-MakeNoteQueue-*.log""" % 'Any validated notes will be automatically uploaded once your Anki collection is reopened.\n\n' if uploadAfterValidation else '') mw.col.close() # mw.closeAllCollectionWindows() @@ -146,7 +146,7 @@ def see_also(steps=None): if isinstance(steps, int): steps = [steps] showAlerts = (len(steps) == 1) if 1 in steps: - # Should be unnecessary once See Also algorithms are finalized + # Should be unnecessary once See Also algorithms are finalized log(" > See Also: Step 1: Processing Un Added See Also Notes") controller.process_unadded_see_also_notes() if 2 in steps: diff --git a/anknotes/setup.cfg b/anknotes/setup.cfg new file mode 100644 index 0000000..a4adf98 --- /dev/null +++ b/anknotes/setup.cfg @@ -0,0 +1,3 @@ +[pep8] +ignore = E701 +max-line-length = 160 \ No newline at end of file diff --git a/anknotes_standAlone_template.py b/anknotes_standAlone_template.py new file mode 100644 index 0000000..b2780be --- /dev/null +++ b/anknotes_standAlone_template.py @@ -0,0 +1,112 @@ +import os +from anknotes import stopwatch +import time +try: + from lxml import etree + eTreeImported = True +except: + eTreeImported = False +if eTreeImported: + + try: + from pysqlite2 import dbapi2 as sqlite + except ImportError: + from sqlite3 import dbapi2 as sqlite + + # Anknotes Module Imports for Stand Alone Scripts + from anknotes import evernote as evernote + + # Anknotes Shared Imports + from anknotes.shared import * + from anknotes.error import * + from anknotes.toc import TOCHierarchyClass + + # Anknotes Class Imports + from anknotes.AnkiNotePrototype import AnkiNotePrototype + from anknotes.EvernoteNoteTitle import generateTOCTitle + + # Anknotes Main Imports + from anknotes.Anki import Anki + from anknotes.ankEvernote import Evernote + from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher + from anknotes.EvernoteNotes import EvernoteNotes + from anknotes.EvernoteNotePrototype import EvernoteNotePrototype + from anknotes.EvernoteImporter import EvernoteImporter + + # Evernote Imports + from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec + from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException + from anknotes.evernote.api.client import EvernoteClient + + ankDBSetLocal() + db = ankDB() + db.Init() + + failed_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) + pending_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 0" % TABLES.MAKE_NOTE_QUEUE) + success_queued_items = db.all("SELECT * FROM %s WHERE validation_status = -1 " % TABLES.MAKE_NOTE_QUEUE) + + currentLog = 'Successful' + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d SUCCESSFUL MAKE NOTE QUEUE ITEMS " % len(success_queued_items), 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + + for result in success_queued_items: + line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' + line += result['title'] + log(line, 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=False) + + currentLog = 'Failed' + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d FAILED MAKE NOTE QUEUE ITEMS " % len(failed_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + + for result in failed_queued_items: + line = '%-60s ' % (result['title'] + ':') + line += (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW" + line += result['validation_result'] + log(line, 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + log("------------------------------------------------\n", 'MakeNoteQueue-' + currentLog, timestamp=False) + log(result['contents'], 'MakeNoteQueue-' + currentLog, timestamp=False) + log("------------------------------------------------\n", 'MakeNoteQueue-' + currentLog, timestamp=False) + + EN = Evernote() + + currentLog = 'Pending' + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d PENDING MAKE NOTE QUEUE ITEMS " % len(pending_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + + timerFull = stopwatch.Timer() + for result in pending_queued_items: + guid = result['guid'] + noteContents = result['contents'] + noteTitle = result['title'] + line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' + + success, errors = EN.validateNoteContent(noteContents, noteTitle) + validation_status = 1 if success else -1 + + line = " SUCCESS! " if success else " FAILURE: " + line += ' ' if result['guid'] else ' NEW ' + # line += ' %-60s ' % (result['title'] + ':') + if not success: + errors = '\n * ' + '\n * '.join(errors) + log(line, 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + else: + errors = '\n'.join(errors) + + sql = "UPDATE %s SET validation_status = %d, validation_result = '%s' WHERE " % (TABLES.MAKE_NOTE_QUEUE, validation_status, escape_text_sql(errors)) + if guid: + sql += "guid = '%s'" % guid + else: + sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) + + db.execute(sql) + + timerFull.stop() + log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + + db.commit() + db.close() diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..a4adf98 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[pep8] +ignore = E701 +max-line-length = 160 \ No newline at end of file From 7d1d44660454066171fd3db40e9d7029ef02b563 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli Date: Thu, 17 Sep 2015 00:07:19 -0400 Subject: [PATCH 04/70] Added Command Line Tools such as Find Deleted Notes --- anknotes/AnkiNotePrototype.py | 2 +- anknotes/Controller.py | 84 ++++++++++-------- anknotes/EvernoteImporter.py | 63 +++++++++++--- anknotes/EvernoteNoteFetcher.py | 7 +- anknotes/__main__.py | 49 ++++++++++- anknotes/ankEvernote.py | 110 +++++++++++++++--------- anknotes/constants.py | 3 + anknotes/extra/graphics/Tomato-icon.ico | Bin 0 -> 84634 bytes anknotes/extra/graphics/Tomato-icon.png | Bin 0 -> 61861 bytes anknotes/find_deleted_notes.py | 45 +++++++--- anknotes/graphics.py | 1 + anknotes/html.py | 20 +++-- anknotes/logging.py | 55 ++++++++---- anknotes/menu.py | 86 +++++++++++++++--- anknotes/shared.py | 6 +- anknotes/structs.py | 55 ++++++------ anknotes/toc.py | 22 +++-- anknotes_standAlone.py | 1 - find_deleted_notes.py | 1 + 19 files changed, 424 insertions(+), 186 deletions(-) create mode 100644 anknotes/extra/graphics/Tomato-icon.ico create mode 100644 anknotes/extra/graphics/Tomato-icon.png create mode 100644 find_deleted_notes.py diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 2715993..fa7f46d 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -182,7 +182,7 @@ def process_note_content(self): log_error("\nERROR processing note, Step 2.2. Content: %s" % content) # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link)" - content = re.sub(r']+>(?P\(Image Link.*\))</a>', + content = re.sub(r'<a href="(?P<URL>.+)"[^>]*>(?P<Title>\(Image Link.*\))</a>', r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', content) diff --git a/anknotes/Controller.py b/anknotes/Controller.py index 158f1f7..9cceaf8 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -88,10 +88,14 @@ def upload_validated_notes(self, automated=False): queries1 = [] queries2 = [] noteFetcher = EvernoteNoteFetcher() + SIMULATE = True if len(dbRows) == 0: if not automated: - report_tooltips(" > Upload of Validated Notes Aborted", "No Qualifying Validated Notes Found") + show_report(" > Upload of Validated Notes Aborted", "No Qualifying Validated Notes Found") return + else: + log(" > Upload of Validated Notes Initiated", "%d Successfully Validated Notes Found" % len(dbRows)) + for dbRow in dbRows: entry = EvernoteValidationEntry(dbRow) evernote_guid, rootTitle, contents, tagNames, notebookGuid = entry.items() @@ -99,7 +103,10 @@ def upload_validated_notes(self, automated=False): if not ANKNOTES.UPLOAD_AUTO_TOC_NOTES or ( ANKNOTES.AUTO_TOC_NOTES_MAX > -1 and count_update + count_create >= ANKNOTES.AUTO_TOC_NOTES_MAX): continue - status, whole_note = self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid, + if SIMULATE: + status = EvernoteAPIStatus.Success + else: + status, whole_note = self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid, validated=True) if status.IsError: error += 1 @@ -109,35 +116,40 @@ def upload_validated_notes(self, automated=False): continue count += 1 if status.IsSuccess: - noteFetcher.addNoteFromServerToDB(whole_note, tagNames) - note = EvernoteNotePrototype(whole_note=whole_note, tags=tagNames) + if not SIMULATE: + noteFetcher.addNoteFromServerToDB(whole_note, tagNames) + note = EvernoteNotePrototype(whole_note=whole_note, tags=tagNames) if evernote_guid: - notes_updated.append(note) + if not SIMULATE: + notes_updated.append(note) queries1.append([evernote_guid]) count_update += 1 else: - notes_created.append(note) + if not SIMULATE: + notes_created.append(note) queries2.append([rootTitle, contents]) count_create += 1 - if count_update + count_create > 0: + if not SIMULATE and count_update + count_create > 0: number_updated = self.anki.update_evernote_notes(notes_updated) number_created = self.anki.add_evernote_notes(notes_created) + count_max = len(dbRows) - str_tip_header = "%d of %d total Validated note(s) successfully generated." % (count, len(dbRows)) + str_tip_header = "%s Validated Note(s) successfully generated." % counts_as_str(count, count_max) str_tips = [] - if count_create: str_tips.append("%d Validated note(s) were newly created " % count_create) - if number_created: str_tips.append("-%d of these were successfully added to Anki " % number_created) - if count_update: str_tips.append( - "%d Validated note(s) already exist in local db and were updated" % count_update) - if number_updated: str_tips.append("-%d of these were successfully updated in Anki " % number_updated) - if error > 0: str_tips.append("%d error(s) occurred " % error) - report_tooltips(" > Upload of Validated Notes Complete", str_tip_header, str_tips) + if count_create: str_tips.append("%s Validated Note(s) were newly created " % counts_as_str(count_create)) + if number_created: str_tips.append("-%-3d of these were successfully added to Anki " % number_created) + if count_update: str_tips.append("%s Validated Note(s) already exist in local db and were updated" % counts_as_str(count_update)) + if number_updated: str_tips.append("-%-3d of these were successfully updated in Anki " % number_updated) + if error > 0: str_tips.append("%d Error(s) occurred " % error) + show_report(" > Upload of Validated Notes Complete", str_tip_header, str_tips) if len(queries1) > 0: ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.MAKE_NOTE_QUEUE, queries1) if len(queries2) > 0: ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.MAKE_NOTE_QUEUE, queries2) + log(queries1) + ankDB().commit() return status, count, exist def create_auto_toc(self): @@ -168,7 +180,7 @@ def create_auto_toc(self): :type: list[EvernoteNote] """ if len(dbRows) == 0: - report_tooltips(" > TOC Creation Aborted", 'No Qualifying Root Titles Found') + show_report(" > TOC Creation Aborted", 'No Qualifying Root Titles Found') return for dbRow in dbRows: rootTitle, contents, tagNames, notebookGuid = dbRow.items() @@ -186,14 +198,17 @@ def create_auto_toc(self): "SELECT guid, content FROM %s WHERE UPPER(title) = ? AND tagNames LIKE '%%,' || ? || ',%%'" % TABLES.EVERNOTE.NOTES, rootTitle.upper(), EVERNOTE.TAG.AUTO_TOC) evernote_guid = None - noteBody = self.evernote.makeNoteBody(contents, encode=False) + noteBody = self.evernote.makeNoteBody(contents, encode=True) + + noteBody2 = self.evernote.makeNoteBody(contents, encode=True) if old_values: evernote_guid, old_content = old_values - if old_content == noteBody: + if old_content == noteBody or old_content == noteBody2: count += 1 count_update_skipped += 1 continue - log(generate_diff(old_content, noteBody), 'AutoTOC-Create-Diffs') + log(generate_diff(old_content, noteBody2), 'AutoTOC-Create-Diffs') + continue if not ANKNOTES.UPLOAD_AUTO_TOC_NOTES or ( ANKNOTES.AUTO_TOC_NOTES_MAX > -1 and count_update + count_create >= ANKNOTES.AUTO_TOC_NOTES_MAX): continue @@ -221,22 +236,19 @@ def create_auto_toc(self): if count_update + count_create > 0: number_updated = self.anki.update_evernote_notes(notes_updated) number_created = self.anki.add_evernote_notes(notes_created) - - str_tip_header = "%d of %d total Auto TOC note(s) successfully generated" % (count + count_queued, len(dbRows)) + count_total = count + count_queued + count_max = len(dbRows) + str_tip_header = "%s Auto TOC note(s) successfully generated" % counts_as_str(count_total, count_max) str_tips = [] - if count_create: str_tips.append("%d Auto TOC note(s) were newly created " % count_create) + if count_create: str_tips.append("%-3d Auto TOC note(s) were newly created " % count_create) if number_created: str_tips.append("-%d of these were successfully added to Anki " % number_created) - if count_queued_create: str_tips.append( - "%d new Auto TOC note(s) were queued to be added to Anki " % count_queued_create) - if count_update: str_tips.append( - "%d Auto TOC note(s) already exist in local db and were updated" % count_update) - if number_updated: str_tips.append("-%d of these were successfully updated in Anki " % number_updated) - if count_queued_update: str_tips.append( - "%d Auto TOC note(s) already exist in local db and were queued to be updated in Anki" % count_queued_update) - if count_update_skipped: str_tips.append( - "%d Auto TOC note(s) already exist in local db and were unchanged" % count_update_skipped) - if error > 0: str_tips.append("%d error(s) occurred " % error) - report_tooltips(" > TOC Creation Complete: ", str_tip_header, str_tips) + if count_queued_create: str_tips.append("-%s Auto TOC note(s) are brand new and and were queued to be added to Anki " % counts_as_str(count_queued_create)) + if count_update: str_tips.append("%-3d Auto TOC note(s) already exist in local db and were updated" % count_update) + if number_updated: str_tips.append("-%s of these were successfully updated in Anki " % counts_as_str(number_updated)) + if count_queued_update: str_tips.append("-%s Auto TOC note(s) already exist in local db and were queued to be updated in Anki" % counts_as_str(count_queued_update)) + if count_update_skipped: str_tips.append("-%s Auto TOC note(s) already exist in local db and were unchanged" % counts_as_str(count_update_skipped)) + if error > 0: str_tips.append("%d Error(s) occurred " % error) + show_report(" > TOC Creation Complete: ", str_tip_header, str_tips) if count_queued > 0: ankDB().commit() @@ -253,7 +265,11 @@ def proceed(self, auto_paging=False): self.evernoteImporter.evernote = self.evernote self.evernoteImporter.forceAutoPage = self.forceAutoPage self.evernoteImporter.auto_page_callback = self.auto_page_callback + if not hasattr(self, 'currentPage'): + self.currentPage = 1 self.evernoteImporter.currentPage = self.currentPage + if hasattr(self, 'ManualGUIDs'): + self.evernoteImporter.ManualGUIDs = self.ManualGUIDs self.evernoteImporter.proceed(auto_paging) def resync_with_local_db(self): @@ -264,4 +280,4 @@ def resync_with_local_db(self): number = self.anki.update_evernote_notes(notes, log_update_if_unchanged=False) tooltip = '%d Entries in Local DB<BR>%d Evernote Notes Created<BR>%d Anki Notes Successfully Updated' % ( len(evernote_guids), local_count, number) - report_tooltips('Resync with Local DB Complete', tooltip) + show_report('Resync with Local DB Complete', tooltip) diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index 850c61b..2829ef4 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -45,10 +45,32 @@ class EvernoteImporter: evernote = None """:type : Evernote""" updateExistingNotes = UpdateExistingNotes.UpdateNotesInPlace + @property + def ManualMetadataMode(self): + return (self.ManualGUIDs is not None and len(self.ManualGUIDs) > 0) def __init(self): self.updateExistingNotes = mw.col.conf.get(SETTINGS.UPDATE_EXISTING_NOTES, UpdateExistingNotes.UpdateNotesInPlace) + self.ManualGUIDs = None + + def override_evernote_metadata(self): + guids = self.ManualGUIDs + self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) + self.MetadataProgress.Total = len(guids) + self.MetadataProgress.Current = min(self.MetadataProgress.Total - self.MetadataProgress.Offset, 250) + result = NotesMetadataList() + result.totalNotes = len(guids) + result.updateCount = -1 + result.startIndex = self.MetadataProgress.Offset + result.notes = [] + """:type : list[NoteMetadata]""" + for i in range(self.MetadataProgress.Offset, self.MetadataProgress.Completed + 1): + result.notes.append(NoteMetadata(guids[i])) + result.totalNotes = len(guids) + self.MetadataProgress.loadResults(result) + self.evernote.metadata = self.MetadataProgress.NotesMetadata + return True def get_evernote_metadata(self): """ @@ -81,6 +103,7 @@ def get_evernote_metadata(self): self.MetadataProgress.Status = EvernoteAPIStatus.SocketError return False raise + self.MetadataProgress.loadResults(result) self.evernote.metadata = self.MetadataProgress.NotesMetadata log(" - Metadata Results: %s" % self.MetadataProgress.Summary, timestamp=False) @@ -91,6 +114,8 @@ def update_in_anki(self, evernote_guids): :rtype : EvernoteNoteFetcherResults """ Results = self.evernote.create_evernote_notes(evernote_guids) + if self.ManualMetadataMode: + self.evernote.check_notebooks_up_to_date() self.anki.notebook_data = self.evernote.notebook_data Results.Imported = self.anki.update_evernote_notes(Results.Notes) return Results @@ -100,6 +125,8 @@ def import_into_anki(self, evernote_guids): :rtype : EvernoteNoteFetcherResults """ Results = self.evernote.create_evernote_notes(evernote_guids) + if self.ManualMetadataMode: + self.evernote.check_notebooks_up_to_date() self.anki.notebook_data = self.evernote.notebook_data Results.Imported = self.anki.add_evernote_notes(Results.Notes) return Results @@ -115,7 +142,10 @@ def check_note_sync_status(self, evernote_guids): for evernote_guid in evernote_guids: db_usn = ankDB().scalar("SELECT updateSequenceNum FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, evernote_guid) - server_usn = self.evernote.metadata[evernote_guid].updateSequenceNum + if not self.evernote.metadata[evernote_guid].updateSequenceNum: + server_usn = 'N/A' + else: + server_usn = self.evernote.metadata[evernote_guid].updateSequenceNum if evernote_guid in self.anki.usns: current_usn = self.anki.usns[evernote_guid] if current_usn == str(server_usn): @@ -129,6 +159,8 @@ def check_note_sync_status(self, evernote_guids): current_usn = 'N/A' log_info = 'NO ANKI USN EXISTS' if log_info: + if not self.evernote.metadata[evernote_guid].updateSequenceNum: + log_info += ' (Unable to find Evernote Metadata) ' log(" > USN check for note '%s': %s: db/current/server = %s,%s,%s" % ( evernote_guid, log_info, str(db_usn), str(current_usn), str(server_usn)), 'usn') return notes_already_up_to_date @@ -161,19 +193,24 @@ def proceed_start(self, auto_paging=False): def proceed_find_metadata(self, auto_paging=False): global latestEDAMRateLimit, latestSocketError - anki_note_ids = self.anki.get_anknotes_note_ids() - anki_evernote_guids = self.anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) - self.get_evernote_metadata() + + # anki_note_ids = self.anki.get_anknotes_note_ids() + # anki_evernote_guids = self.anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) + + if self.ManualMetadataMode: + self.override_evernote_metadata() + else: + self.get_evernote_metadata() if self.MetadataProgress.Status == EvernoteAPIStatus.RateLimitError: m, s = divmod(latestEDAMRateLimit, 60) - report_tooltips(" > Error: Delaying Operation", + show_report(" > Error: Delaying Operation", "Over the rate limit when searching for Evernote metadata<BR>Evernote requested we wait %d:%02d min" % ( m, s), delay=5) mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(auto_paging), False) return False elif self.MetadataProgress.Status == EvernoteAPIStatus.SocketError: - report_tooltips(" > Error: Delaying Operation:", + show_report(" > Error: Delaying Operation:", "%s when searching for Evernote metadata" % latestSocketError['friendly_error_msg'], "We will try again in 30 seconds", delay=5) mw.progress.timer(30000, lambda: self.proceed(auto_paging), False) @@ -181,7 +218,7 @@ def proceed_find_metadata(self, auto_paging=False): self.ImportProgress = EvernoteImportProgress(self.anki, self.MetadataProgress) self.ImportProgress.loadAlreadyUpdated( - self.check_note_sync_status(self.ImportProgress.GUIDs.Server.Existing.All)) + [] if self.ManualMetadataMode else self.check_note_sync_status(self.ImportProgress.GUIDs.Server.Existing.All)) log(" - " + self.ImportProgress.Summary + "\n", timestamp=False) def proceed_import_notes(self): @@ -194,7 +231,7 @@ def proceed_import_notes(self): self.anki.delete_anki_cards(self.ImportProgress.GUIDs.Server.Existing.OutOfDate) self.ImportProgress.processDeleteAndUpdateResults( self.import_into_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) - report_tooltips(" > Import Complete", self.ImportProgress.ResultsSummaryLines) + show_report(" > Import Complete", self.ImportProgress.ResultsSummaryLines) self.anki.stop_editing() self.anki.collection().autosave() @@ -206,13 +243,13 @@ def proceed_autopage(self): status = self.ImportProgress.Status if status == EvernoteAPIStatus.RateLimitError: m, s = divmod(latestEDAMRateLimit, 60) - report_tooltips(" > Error: Delaying Auto Paging", + show_report(" > Error: Delaying Auto Paging", "Over the rate limit when getting Evernote notes<BR>Evernote requested we wait %d:%02d min" % ( m, s), delay=5) mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(True), False) return False if status == EvernoteAPIStatus.SocketError: - report_tooltips(" > Error: Delaying Auto Paging:", + show_report(" > Error: Delaying Auto Paging:", "%s when getting Evernote notes" % latestSocketError[ 'friendly_error_msg'], "We will try again in 30 seconds", delay=5) @@ -221,7 +258,7 @@ def proceed_autopage(self): if self.MetadataProgress.IsFinished: self.currentPage = 1 if self.forceAutoPage: - report_tooltips(" > Terminating Auto Paging", + show_report(" > Terminating Auto Paging", "All %d notes have been processed and forceAutoPage is True" % self.MetadataProgress.Total, delay=5) self.auto_page_callback() @@ -233,7 +270,7 @@ def proceed_autopage(self): self.MetadataProgress.Total suffix = "Per EVERNOTE.PAGING_RESTART_INTERVAL, " else: - report_tooltips(" > Completed Auto Paging", + show_report(" > Completed Auto Paging", "All %d notes have been processed and EVERNOTE.PAGING_RESTART_WHEN_COMPLETE is FALSE" % self.MetadataProgress.Total, delay=5) return True @@ -261,7 +298,7 @@ def proceed_autopage(self): if restart > 0: m, s = divmod(restart, 60) suffix += "will delay for %d:%02d min before continuing\n" % (m, s) - report_tooltips(restart_title, (restart_msg + suffix).split('<BR>'), delay=5) + show_report(restart_title, (restart_msg + suffix).split('<BR>'), delay=5) if restart > 0: mw.progress.timer(restart * 1000, lambda: self.proceed(True), False) return False diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index 21ef598..01950b7 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -24,14 +24,13 @@ def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): self.tagGuids = [] self.use_local_db_only = use_local_db_only self.__update_sequence_number__ = -1 + if evernote: self.evernote = evernote if not evernote_guid: self.evernote_guid = "" return self.evernote_guid = evernote_guid - if evernote: - self.evernote = evernote - if not self.use_local_db_only: - self.__update_sequence_number__ = self.evernote.metadata[self.evernote_guid].updateSequenceNum + if evernote and not self.use_local_db_only: + self.__update_sequence_number__ = self.evernote.metadata[self.evernote_guid].updateSequenceNum self.getNote() def UpdateSequenceNum(self): diff --git a/anknotes/__main__.py b/anknotes/__main__.py index 6ce58a6..a3511eb 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -16,8 +16,11 @@ ### Anki Imports from anki.hooks import wrap, addHook from aqt.preferences import Preferences -from aqt import mw - +from aqt import mw, browser +# from aqt.qt import QIcon, QTreeWidget, QTreeWidgetItem +from aqt.qt import Qt, QIcon, QTreeWidget, QTreeWidgetItem +# from aqt.qt.Qt import MatchFlag +# from aqt.qt.qt import MatchFlag def import_timer_toggle(): title = "&Enable Auto Import On Profile Load" @@ -39,8 +42,42 @@ def import_timer_toggle(): mw.progress.timer(importDelay, menu.import_from_evernote, False) +def _findEdited((val, args)): + try: days = int(val) + except ValueError: return + return "c.mod > %d" % (time.time() - days * 86400) + +class CallbackItem(QTreeWidgetItem): + def __init__(self, root, name, onclick, oncollapse=None): + QTreeWidgetItem.__init__(self, root, [name]) + self.onclick = onclick + self.oncollapse = oncollapse + +def anknotes_browser_tagtree_wrap(self, root, _old): + """ + + :param root: + :type root : QTreeWidget + :param _old: + :return: + """ + tags = [(_("Edited This Week"), "view-pim-calendar.png", "edited:7")] + for name, icon, cmd in tags: + onclick = lambda c=cmd: self.setFilter(c) + widgetItem = QTreeWidgetItem([name]) + widgetItem.onclick = onclick + widgetItem.setIcon(0, QIcon(":/icons/" + icon)) + root = _old(self, root) + indices = root.findItems(_("Added Today"), Qt.MatchFixedString) + index = (root.indexOfTopLevelItem(indices[0]) + 1) if indices else 3 + root.insertTopLevelItem(index, widgetItem) + return root + +def anknotes_search_hook(search): + if not 'edited' in search: + search['edited'] = _findEdited + def anknotes_profile_loaded(): - log("Profile Loaded", "load") menu.anknotes_load_menu_settings() if ANKNOTES.ENABLE_VALIDATION and ANKNOTES.AUTOMATE_VALIDATION: menu.upload_validated_notes(True) @@ -52,12 +89,16 @@ def anknotes_profile_loaded(): # resync_with_local_db() # menu.see_also() # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) - menu.see_also(3) + # menu.see_also(3) + # menu.see_also(4) + pass def anknotes_onload(): addHook("profileLoaded", anknotes_profile_loaded) + addHook("search", anknotes_search_hook) + browser.Browser._systemTagTree = wrap(browser.Browser._systemTagTree, anknotes_browser_tagtree_wrap, "around") menu.anknotes_setup_menu() Preferences.setupOptions = wrap(Preferences.setupOptions, settings.setup_evernote) diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 711fa74..636c5cb 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -22,6 +22,7 @@ if not eTreeImported: ### Anknotes Class Imports from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher + from anknotes.EvernoteNotePrototype import EvernoteNotePrototype ### Evernote Imports from anknotes.evernote.edam.type.ttypes import Note as EvernoteNote @@ -106,55 +107,48 @@ def initialize_note_store(self): return 0 def validateNoteBody(self, noteBody, title="Note Body"): - timerFull = stopwatch.Timer() - timerInterval = stopwatch.Timer(False) + # timerFull = stopwatch.Timer() + # timerInterval = stopwatch.Timer(False) if not self.DTD: - timerInterval.reset() + timerInterval = stopwatch.Timer() log("Loading ENML DTD", "lxml", timestamp=False, do_print=True) self.DTD = etree.DTD(ANKNOTES.ENML_DTD) - log("DTD Loaded in %s" % str(timerInterval), "lxml", timestamp=False, do_print=True) + log("DTD Loaded in %s\n" % str(timerInterval), "lxml", timestamp=False, do_print=True) timerInterval.stop() + del timerInterval - timerInterval.reset() - log("Loading XML for %s" % title, "lxml", timestamp=False, do_print=False) + # timerInterval.reset() + # log("Loading XML for %s" % title, "lxml", timestamp=False, do_print=False) try: tree = etree.parse(StringIO(noteBody)) except Exception as e: - timer_header = ' at %s. The whole process took %s' % (str(timerInterval), str(timerFull)) - log_str = "XML Loading of %s failed.%s\n - Error Details: %s" - log_str_error = log_str % (title, '', str(e)) - log_str = log_str % (title, timer_header, str(e)) + # timer_header = ' at %s. The whole process took %s' % (str(timerInterval), str(timerFull)) + log_str = "XML Loading of %s failed.\n - Error Details: %s" % (title, str(e)) log(log_str, "lxml", timestamp=False, do_print=True) - log_error(log_str_error) - return False, log_str_error - log("XML Loaded in %s for %s" % (str(timerInterval), title), "lxml", timestamp=False, do_print=False) + log_error(log_str, False) + return False, log_str + # log("XML Loaded in %s for %s" % (str(timerInterval), title), "lxml", timestamp=False, do_print=False) # timerInterval.stop() - timerInterval.reset() - log("Validating %s with ENML DTD" % title, "lxml", timestamp=False, do_print=False) + # timerInterval.reset() + # log("Validating %s with ENML DTD" % title, "lxml", timestamp=False, do_print=False) try: success = self.DTD.validate(tree) except Exception as e: - timer_header = ' at %s. The whole process took %s' % (str(timerInterval), str(timerFull)) - log_str = "DTD Validation of %s failed.%s\n - Error Details: %s" - log_str_error = log_str % (title, '', str(e)) - log_str = log_str % (title, timer_header, str(e)) + log_str = "DTD Validation of %s failed.\n - Error Details: %s" % (title, str(e)) log(log_str, "lxml", timestamp=False, do_print=True) - log_error(log_str_error) - return False, log_str_error - log("Validation %s in %s. Entire process took %s" % ( - "Succeeded" if success else "Failed", str(timerInterval), str(timerFull)), "lxml", timestamp=False, - do_print=False) - if not success: - print "Validation %-9s for %s" % ("Succeeded" if success else "Failed", title) + log_error(log_str, False) + return False, log_str + log("Validation %-9s for %s" % ("Succeeded" if success else "Failed", title), "lxml", timestamp=False, + do_print=True) errors = self.DTD.error_log.filter_from_errors() if not success: log_str = "DTD Validation Errors for %s: \n%s\n" % (title, errors) - log(log_str) - log_error(log_str) - timerInterval.stop() - timerFull.stop() - del timerInterval - del timerFull + log(log_str, "lxml", timestamp=False) + log_error(log_str, False) + # timerInterval.stop() + # timerFull.stop() + # del timerInterval + # del timerFull return success, errors def validateNoteContent(self, content, title="Note Contents"): @@ -197,14 +191,15 @@ def makeNoteBody(content, resources=[], encode=True): def addNoteToMakeNoteQueue(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=[], guid=None): - sql = "SELECT validation_status FROM %s WHERE " % TABLES.MAKE_NOTE_QUEUE + sql = "FROM %s WHERE " % TABLES.MAKE_NOTE_QUEUE if guid: sql += "guid = '%s'" % guid else: sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) - status = ankDB().execute(sql) - if status is 1: - return EvernoteAPIStatus.Success + statuses = ankDB().all('SELECT validation_status ' + sql) + if len(statuses) > 0: + if str(statuses[0]['validation_status']) == '1': return EvernoteAPIStatus.Success + ankDB().execute("DELETE " + sql) # log_sql(sql) # log_sql([ guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook]) ankDB().execute( @@ -325,7 +320,6 @@ def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): if not hasattr(self, 'guids') or evernote_guids: self.evernote_guids = evernote_guids if not use_local_db_only: self.check_ancillary_data_up_to_date() - notes = [] fetcher = EvernoteNoteFetcher(self, use_local_db_only=use_local_db_only) if len(evernote_guids) == 0: fetcher.results.Status = EvernoteAPIStatus.EmptyRequest @@ -347,6 +341,24 @@ def update_ancillary_data(self): self.update_tags_db() self.update_notebook_db() + def check_notebook_metadata(self, notes): + """ + :param notes: + :type : list[EvernoteNotePrototype] + :return: + """ + if not hasattr(self, 'notebook_data'): + self.notebook_data = {x.guid:{'stack': x.stack, 'name': x.name} for x in ankDB().execute("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS) } + for note in notes: + assert(isinstance(note, EvernoteNotePrototype)) + if not note.NotebookGuid in self.notebook_data: + self.update_notebook_db() + if not note.NotebookGuid in self.notebook_data: + log_error("FATAL ERROR: Notebook GUID %s for Note %s: %s does not exist on Evernote servers" % (note.NotebookGuid, note.Guid, note.Title)) + raise EDAMNotFoundException() + return False + return True + def check_notebooks_up_to_date(self): for evernote_guid in self.evernote_guids: note_metadata = self.metadata[evernote_guid] @@ -397,6 +409,7 @@ def check_tags_up_to_date(self): return False else: note_metadata = self.metadata[evernote_guid] + if not note_metadata.tagGuids: continue for tag_guid in note_metadata.tagGuids: if tag_guid not in self.tag_data: tag = EvernoteTag(fetch_guid=tag_guid) @@ -421,6 +434,7 @@ def update_tags_db(self): return None raise data = [] + if not hasattr(self, 'tag_data'): self.tag_data = {} for tag in tags: self.tag_data[tag.guid] = tag.name data.append([tag.guid, tag.name, tag.parentGuid, tag.updateSequenceNum]) @@ -433,11 +447,25 @@ def update_tags_db(self): def get_tag_names_from_evernote_guids(self, tag_guids_original): tagGuids = [] tagNames = [] + if not hasattr(self, 'tag_data'): + self.tag_data = {x.guid: x.name for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS)} + missing_tags = [x for x in tag_guids_original if x not in self.tag_data] + if len(missing_tags) > 0: + self.update_tags_db() + missing_tags = [x for x in tag_guids_original if x not in self.tag_data] + if len(missing_tags) > 0: + log_error("FATAL ERROR: Tag Guid(s) %s were not found on the Evernote Servers" % str(missing_tags)) + raise EDAMNotFoundException() + tagNamesToImport = get_tag_names_to_import({x: self.tag_data[x] for x in tag_guids_original}) - for k, v in tagNamesToImport.items(): - tagGuids.append(k) - tagNames.append(v) - tagNames = sorted(tagNames, key=lambda s: s.lower()) + """:type : dict[string, EvernoteTag]""" + if tagNamesToImport: + is_struct = None + for k, v in tagNamesToImport.items(): + if is_struct is None: is_struct = isinstance(v, EvernoteTag) + tagGuids.append(k) + tagNames.append(v.Name if is_struct else v) + tagNames = sorted(tagNames, key=lambda s: s.lower()) return tagGuids, tagNames diff --git a/anknotes/constants.py b/anknotes/constants.py index f66c574..6c18840 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -14,10 +14,13 @@ class ANKNOTES: TEMPLATE_FRONT = os.path.join(FOLDER_ANCILLARY, 'FrontTemplate.htm') CSS = u'_AviAnkiCSS.css' ENML_DTD = os.path.join(FOLDER_ANCILLARY, 'enml2.dtd') + TABLE_OF_CONTENTS_ENEX = os.path.join(FOLDER_TESTING, "Table of Contents.enex") VALIDATION_SCRIPT = os.path.join(os.path.dirname(PATH), 'test.py') # anknotes-standAlone.py') + FIND_DELETED_NOTES_SCRIPT = os.path.join(os.path.dirname(PATH), 'find_deleted_notes.py') # anknotes-standAlone.py') ICON_EVERNOTE_WEB = os.path.join(FOLDER_GRAPHICS, u'evernote_web.ico') IMAGE_EVERNOTE_WEB = ICON_EVERNOTE_WEB.replace('.ico', '.png') ICON_EVERNOTE_ARTCORE = os.path.join(FOLDER_GRAPHICS, u'evernote_artcore.ico') + ICON_TOMATO = os.path.join(FOLDER_GRAPHICS, u'Tomato-icon.ico') IMAGE_EVERNOTE_ARTCORE = ICON_EVERNOTE_ARTCORE.replace('.ico', '.png') EVERNOTE_CONSUMER_KEY = "holycrepe" EVERNOTE_IS_SANDBOXED = False diff --git a/anknotes/extra/graphics/Tomato-icon.ico b/anknotes/extra/graphics/Tomato-icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c6c3ee558ad479ee0760255942d738540d71c01c GIT binary patch literal 84634 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 P{p{~#yjtdnC4KV$<Hj12 literal 0 HcmV?d00001 diff --git a/anknotes/extra/graphics/Tomato-icon.png b/anknotes/extra/graphics/Tomato-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..7015a4c1301172251a425d5bd00e0816ac837f32 GIT binary patch literal 61861 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%0AsA1YfrE%j<Qam)TCl>2JdOHN|E8v(w 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 BTM7UG literal 0 HcmV?d00001 diff --git a/anknotes/find_deleted_notes.py b/anknotes/find_deleted_notes.py index f782dea..bfc9866 100644 --- a/anknotes/find_deleted_notes.py +++ b/anknotes/find_deleted_notes.py @@ -6,17 +6,25 @@ from anknotes.shared import * + Error = sqlite.Error ankDBSetLocal() -PATH = os.path.dirname(os.path.abspath(__file__)) + '\\extra\\testing\\' -ENNotes = file(os.path.join(PATH, "^ Scratch Note.enex"), 'r').read() +ENNotes = file(ANKNOTES.TABLE_OF_CONTENTS_ENEX, 'r').read() # find = file(os.path.join(PATH, "powergrep-find.txt") , 'r').read().splitlines() # replace = file(os.path.join(PATH, "powergrep-replace.txt") , 'r').read().replace('https://www.evernote.com/shard/s175/nl/19775535/' , '').splitlines() -all_notes = ankDB().execute("SELECT guid, title FROM %s " % TABLES.EVERNOTE.NOTES) +all_notes = ankDB().all("SELECT guid, title FROM %s " % TABLES.EVERNOTE.NOTES) +ankDB().close() find_guids = {} +log1='Find Deleted Notes\\MissingFromAnki' +log2='Find Deleted Notes\\MissingFromEvernote' +log3='Find Deleted Notes\\TitleMismatch' +log_banner(' FIND DELETED EVERNOTE NOTES: EVERNOTE NOTES MISSING FROM ANKI ', log1) +log_banner(' FIND DELETED EVERNOTE NOTES: ANKI NOTES DELETED FROM EVERNOTE ', log2) +log_banner(' FIND DELETED EVERNOTE NOTES: TITLE MISMATCHES ', log3) + for line in all_notes: # line = line.split('::: ') # guid = line[0] @@ -25,7 +33,8 @@ title = line['title'] title = clean_title(title) find_guids[guid] = title - +mismatch=0 +missingfromanki=0 for match in find_evernote_links(ENNotes): guid = match.group('guid') title = match.group('Title') @@ -37,16 +46,28 @@ if find_title_safe == title_safe: del find_guids[guid] else: - print("Found guid match, title mismatch for %s: \n - %s\n - %s" % (guid, title_safe, find_title_safe)) + # print("Found guid match, title mismatch for %s: \n - %s\n - %s" % (guid, title_safe, find_title_safe)) + log_plain(guid + ': ' + title_safe, log3) + mismatch += 1 else: title_safe = str_safe(title) - print("COULD NOT FIND guid for %s: %s" % (guid, title_safe)) + # print("COULD NOT FIND Anknotes database GUID for Evernote Server GUID %s: %s" % (guid, title_safe)) + log_plain(guid + ': ' + title_safe, log1) + missingfromanki += 1 dels = [] -with open(os.path.join(PATH, 'deleted-notes.txt'), 'w') as filesProgress: - for guid, title in find_guids.items(): - print>> filesProgress, str_safe('%s::: %s' % (guid, title)) - dels.append([guid]) +for guid, title in find_guids.items(): + title_safe = str_safe(title) + log_plain(guid + ': ' + title_safe, log2) + dels.append(guid) +print "\nTotal %3d notes deleted from Evernote but still present in Anki" % len(dels) +print "Total %3d notes present in Evernote but not present in Anki" % missingfromanki +print "Total %3d title mismatches" % mismatch -ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, dels) -ankDB().commit() +# confirm = raw_input("Please type in the total number of results (%d) to confirm deletion from the Anknotes DB. Note that the notes will not be deleted from Anknotes' Notes History database.\n >> " % len(dels)) +# +# if confirm == str(len(dels)): +# print "Confirmed!" +# ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, dels) +# ankDB().commit() +# diff --git a/anknotes/graphics.py b/anknotes/graphics.py index c71f236..438fa1c 100644 --- a/anknotes/graphics.py +++ b/anknotes/graphics.py @@ -8,6 +8,7 @@ try: icoEvernoteWeb = QIcon(ANKNOTES.ICON_EVERNOTE_WEB) icoEvernoteArtcore = QIcon(ANKNOTES.ICON_EVERNOTE_ARTCORE) + icoTomato = QIcon(ANKNOTES.ICON_TOMATO) imgEvernoteWeb = QPixmap(ANKNOTES.IMAGE_EVERNOTE_WEB, "PNG") imgEvernoteWebMsgBox = imgEvernoteWeb.scaledToWidth(64) except: diff --git a/anknotes/html.py b/anknotes/html.py index 34a1650..bf9ba64 100644 --- a/anknotes/html.py +++ b/anknotes/html.py @@ -1,7 +1,7 @@ from HTMLParser import HTMLParser from anknotes.constants import SETTINGS from anknotes.db import get_evernote_title_from_guid - +from anknotes.logging import log class MLStripper(HTMLParser): def __init__(self): @@ -36,15 +36,25 @@ def escape_text(title): return title -def unescape_text(title): +def unescape_text(title, try_decoding=False): + title_orig = title global __text_escape_phrases__ - for i in range(0, len(__text_escape_phrases__), 2): - title = title.replace(__text_escape_phrases__[i + 1], __text_escape_phrases__[i]) - title = title.replace(" ", " ") + if try_decoding: title = title.decode('utf-8') + try: + for i in range(0, len(__text_escape_phrases__), 2): + title = title.replace(__text_escape_phrases__[i + 1], __text_escape_phrases__[i]) + title = title.replace(u" ", u" ") + except: + if try_decoding: raise UnicodeError + title_new = unescape_text(title, True) + log(title + '\n' + title_new + '\n\n', 'unicode') + return title_new return title def clean_title(title): + if isinstance(title, str): + title = unicode(title, 'utf-8') title = unescape_text(title) if isinstance(title, str): title = unicode(title, 'utf-8') diff --git a/anknotes/logging.py b/anknotes/logging.py index f39204e..d26c491 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -29,7 +29,7 @@ def print_safe(strr, prefix=''): print str_safe(strr, prefix) -def show_tooltip(text, time_out=3000, delay=None): +def show_tooltip(text, time_out=7000, delay=None): if delay: try: return mw.progress.timer(delay, lambda: tooltip(text, time_out), False) @@ -37,37 +37,50 @@ def show_tooltip(text, time_out=3000, delay=None): pass tooltip(text, time_out) +def counts_as_str(count, max=None): + if max is None: return pad_center(count, 3) + if count == max: return "All %s" % (pad_center(count, 3)) + return "Total %s of %s" % (pad_center(count, 3), pad_center(max, 3)) -def report_tooltips(title, header, log_lines=[], delay=None): +def show_report(title, header, log_lines=[], delay=None, log_header_prefix = ' '*5): lines = [] - for line in header.split('<BR>') + log_lines.join('<BR>').split('<BR>'): - while line[0] is '-': line = '\t' + line[1:] - lines.append('- ' + line) + for line in header.split('<BR>') + ('<BR>'.join(log_lines).split('<BR>') if log_lines else []): + level = 0 + while line and line[level] is '-': level += 1 + lines.append('\t'*level + ('\t- ' if lines else '') + line[level:]) if len(lines) > 1: lines[0] += ': ' log_text = '<BR>'.join(lines) - show_tooltip(log_text, delay=delay) + show_tooltip(log_text.replace('\t', '  '), delay=delay) log_blank() log(title) - log(" " + "-" * 192 + '\n' + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True) + log(" " + "-" * 192 + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True) log_blank() -def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0): +def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0, cancelButton=False, richText=False): global imgEvernoteWebMsgBox, icoEvernoteArtcore msgDefaultButton = QPushButton(icoEvernoteArtcore, "Okay!", mw) + msgCancelButton = QPushButton(icoTomato, "No Thanks", mw) if not isinstance(message, str) and not isinstance(message, unicode): message = str(message) + if richText: textFormat = 1 + messageBox = QMessageBox() messageBox.addButton(msgDefaultButton, QMessageBox.AcceptRole) + if cancelButton: + messageBox.addButton(msgCancelButton, QMessageBox.NoRole) messageBox.setDefaultButton(msgDefaultButton) messageBox.setIconPixmap(imgEvernoteWebMsgBox) messageBox.setTextFormat(textFormat) messageBox.setText(message) messageBox.setWindowTitle(title) messageBox.exec_() - + if not cancelButton: return True + if messageBox.clickedButton() == cancelButton or messageBox.clickedButton() == 0: + return False + return True def diffify(content): for tag in ['div', 'ol', 'ul', 'li']: @@ -75,6 +88,12 @@ def diffify(content): content = re.sub(r'[\r\n]+', '\n', content) return content.splitlines() +def pad_center(val, length=20, favor_right=True): + val = str(val) + pad = max(length - len(val), 0) + pads = [int(round(float(pad) / 2))]*2 + if sum(pads) > pad: pads[favor_right] -= 1 + return ' ' * pads[0] + val + ' ' * pads[1] def generate_diff(value_original, value): try: @@ -113,6 +132,11 @@ def log_plain(content=None, filename='', prefix='', clear=False, extension='log' log(timestamp=False, content=content, filename=filename, prefix=prefix, clear=clear, extension=extension, replace_newline=replace_newline, do_print=do_print) +def log_banner(title, filename, length=80, append_newline=True): + log("-" * length, filename, clear=True, timestamp=False) + log(pad_center(title, length),filename, timestamp=False) + log("-" * length, filename, timestamp=False) + if append_newline: log_blank(filename) def log(content=None, filename='', prefix='', clear=False, timestamp=True, extension='log', replace_newline=None, do_print=False): @@ -137,10 +161,10 @@ def log(content=None, filename='', prefix='', clear=False, timestamp=True, exten except Exception: pass if timestamp or replace_newline is True: - content = content.replace('\r', '\r ').replace('\n', - '\n ') + spacer = '\t'*6 + content = content.replace('\r\n', '\n').replace('\r', '\r'+spacer).replace('\n', '\n'+spacer) if timestamp: - st = '[%s]: ' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) + st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) else: st = '' full_path = os.path.join(ANKNOTES.FOLDER_LOGS, filename) @@ -151,14 +175,9 @@ def log(content=None, filename='', prefix='', clear=False, timestamp=True, exten if do_print: print prefix + ' ' + st + content - -log("Log Loaded", "load") - - def log_sql(value): log(value, 'sql') - def log_error(value, crossPost=True): log(value, '+' if crossPost else '' + 'error') @@ -254,7 +273,7 @@ def get_api_call_count(): call = api_log[i - 1] if not "API_CALL" in call: continue - ts = call.split(': ')[0][2:-1] + ts = call.replace(':\t', ': ').split(': ')[0][2:-1] td = datetime.now() - datetime.strptime(ts, ANKNOTES.DATE_FORMAT) if td < timedelta(hours=1): count += 1 diff --git a/anknotes/menu.py b/anknotes/menu.py index 5a3f39c..30080d9 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -17,6 +17,8 @@ # Anki Imports from aqt.qt import SIGNAL, QMenu, QAction from aqt import mw +from aqt.utils import getText +from anki.storage import Collection DEBUG_RAISE_API_ERRORS = False @@ -30,7 +32,9 @@ def anknotes_setup_menu(): ["&Enable Auto Import On Profile Load", {'action': anknotes_menu_auto_import_changed, 'checkable': True}], ["Note &Validation", [ - ["&Validate Pending Notes", validate_pending_notes], + ["Validate &And Upload Pending Notes", lambda: validate_pending_notes], + ["SEPARATOR", None], + ["&Validate Pending Notes", lambda: validate_pending_notes(True, False)], ["&Upload Validated Notes", upload_validated_notes] ] ], @@ -49,8 +53,14 @@ def anknotes_setup_menu(): ["Step &7: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(7)] ] ], - ["Res&ync with Local DB", resync_with_local_db], - ["Update Evernote &Ancillary Data", update_ancillary_data] + ["&Maintenance Tasks", + [ + ["Find &Deleted Notes", find_deleted_notes], + ["Res&ync with Local DB", resync_with_local_db], + ["Update Evernote &Ancillary Data", update_ancillary_data] + ] + ] + ] ] ] @@ -97,6 +107,17 @@ def anknotes_load_menu_settings(): SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False)) +def import_from_evernote_manual_metadata(guids=None): + if not guids: + guids = find_evernote_guids(file(ANKNOTES.FOLDER_LOGS + 'anknotes-Find Deleted Notes\\MissingFromAnki.log', 'r').read()) + showInfo("Manually downloading %n Notes: \n%s" % (len(guids), str(guids))) + controller = Controller() + controller.evernote.initialize_note_store() + controller.forceAutoPage = True + controller.currentPage = 1 + controller.ManualGUIDs = guids + controller.proceed() + def import_from_evernote(auto_page_callback=None): controller = Controller() controller.evernote.initialize_note_store() @@ -114,28 +135,69 @@ def upload_validated_notes(automated=False): controller = Controller() controller.upload_validated_notes(automated) +def find_deleted_notes(): + showInfo("""In order for this to work, you must create a 'Table of Contents' Note using the Evernote desktop application. Include all notes that you want to sync with Anki. + +Export this note to the following path: '%s'. + +Press Okay to save and close your Anki collection, open the command-line deleted notes detection tool, and then re-open your Anki collection. + +Once the command line tool is done running, you will get a summary of the results, and will be prompted to delete Anki Orphan Notes or download Missing Evernote Notes""" % ANKNOTES.TABLE_OF_CONTENTS_ENEX) + + mw.col.close() + handle = Popen(ANKNOTES.FIND_DELETED_NOTES_SCRIPT, stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) + stdoutdata, stderrdata = handle.communicate() + info = ("ERROR: {%s}\n\n" % stderrdata) if stderrdata else '' + info += "Return data: \n%s" % stdoutdata + showInfo(info) + dels = file(os.path.join(ANKNOTES.FOLDER_LOGS, ANKNOTES.LOG_BASE_NAME + '-Find Deleted Notes\\MissingFromEvernote.log'), 'r').read() + guids = find_evernote_guids(dels) + count = len(guids) + mfile = os.path.join(ANKNOTES.FOLDER_LOGS, ANKNOTES.LOG_BASE_NAME + '-Find Deleted Notes\\MissingFromAnki.log') + missing = file(mfile, 'r').read() + missing_guids = find_evernote_guids(missing) + count_missing = len(missing_guids) + + showInfo("Completed: %s\n\n%s" + ('Press Okay and we will show you a prompt to confirm deletion of the %d orphan Anki notes' % count) if count > 0 else 'No Orpan Anki Notes Found', info[:1000]) + mw.col.reopen() + mw.col.load() + if count > 0: + code = getText("Please enter code 'ANKI_DEL_%d' to delete your orphan Anki note(s)" % count)[0] + if code is 'ANKI_DEL_%d' % count: + ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, [[x] for x in guids]) + ankDB().commit() + show_tooltip("Deleted all %d Orphan Anki Notes" % count, time_out=5000) + if count_missing > 0: + ret = showInfo("Would you like to import %d missing Evernote Notes?\n\n<a href='%s'>Click to view results</a>" % mfile, cancelButton=True, richText=True) + if ret: + show_tooltip("YES !") + # import_from_evernote_manual_metadata() + else: + show_tooltip("NO !") + + + + def validate_pending_notes(showAlerts=True, uploadAfterValidation=True): if showAlerts: showInfo("""Press Okay to save and close your Anki collection, open the command-line note validation tool, and then re-open your Anki collection.%s - Anki will be unresponsive until the validation tool completes. This will take at least 45 seconds. +Anki will be unresponsive until the validation tool completes. This will take at least 45 seconds. - The tool's output will be shown. If it is truncated, you may view the full log in the anknotes addon folder at extra\\logs\\anknotes-MakeNoteQueue-*.log""" - % 'Any validated notes will be automatically uploaded once your Anki collection is reopened.\n\n' if uploadAfterValidation else '') +The tool's output will be shown. If it is truncated, you may view the full log in the anknotes addon folder at extra\\logs\\anknotes-MakeNoteQueue-*.log""" + % ' Any validated notes will be automatically uploaded once your Anki collection is reopened.' if uploadAfterValidation else '') mw.col.close() # mw.closeAllCollectionWindows() handle = Popen(ANKNOTES.VALIDATION_SCRIPT, stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) stdoutdata, stderrdata = handle.communicate() - info = "" - if stderrdata: - info += "ERROR: {%s}\n\n" % stderrdata + info = ("ERROR: {%s}\n\n" % stderrdata) if stderrdata else '' info += "Return data: \n%s" % stdoutdata - - if showAlerts: - showInfo("Completed: %s" % info[:500]) + if showAlerts: showInfo("Completed: %s\n\n%s" % ('Press Okay to begin uploading successfully validated notes to the Evernote Servers' if uploadAfterValidation else '', info[:1000])) mw.col.reopen() + mw.col.load() + if uploadAfterValidation: upload_validated_notes() diff --git a/anknotes/shared.py b/anknotes/shared.py index ccfbb0f..89bf266 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -64,6 +64,8 @@ def get_tag_names_to_import(tagNames, evernoteTags=None, evernoteTagsToDelete=No return sorted([v for v in tagNames if v not in evernoteTags and v not in evernoteTagsToDelete], key=lambda s: s.lower()) +def find_evernote_guids(content): + return [x.group('guid') for x in re.finditer(r'(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)', content)] def find_evernote_links_as_guids(content): return [x.group('guid') for x in find_evernote_links(content)] @@ -71,8 +73,8 @@ def find_evernote_links_as_guids(content): def find_evernote_links(content): # .NET regex saved to regex.txt as 'Finding Evernote Links' - - regex_str = r'<a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)"(?: shape="rect")?(?: style="[^\"].+?")?(?: shape="rect")?>(?P<Title>.+?)</a>' + regex_str = r'<a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?|' \ + r'https://www.evernote.com/shard/(?P<shard>s\d+)/[\w\d]+/(?P<uid>[\d]+?)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))"(?:[^>]*)?(?: style="[^\"].+?")?(?: shape="rect")?>(?P<Title>.+?)</a>' ids = get_evernote_account_ids() if not ids.valid: match = re.search(regex_str, content) diff --git a/anknotes/structs.py b/anknotes/structs.py index 048abdc..1798f15 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -1,13 +1,13 @@ from anknotes.db import * from anknotes.enum import Enum -from anknotes.logging import log, str_safe - +from anknotes.logging import log, str_safe, pad_center # from evernote.edam.notestore.ttypes import NoteMetadata, NotesMetadataList def upperFirst(name): return name[0].upper() + name[1:] +from anknotes.EvernoteNotePrototype import EvernoteNotePrototype class EvernoteStruct(object): success = False @@ -196,43 +196,45 @@ def Remote(self): @property def SummaryShort(self): - add_update_strs = ['New', "Added"] if self.ImportType == EvernoteImportType.Add else ['Existing', - 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated'] + add_update_strs = ['New', "Added"] if self.ImportType == EvernoteImportType.Add else ['Existing', 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated'] return "%d %s Notes Have Been %s" % (self.Imported, add_update_strs[0], add_update_strs[1]) @property def SummaryLines(self): - add_update_strs = ['New', "Added to"] if self.ImportType == EvernoteImportType.Add else ['Existing', - "%s in" % ( - 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated')] - add_update_strs[1] += " Anki" if self.Max is 0: return [] + add_update_strs = ['New', "Added to"] if self.ImportType == EvernoteImportType.Add else ['Existing', "%s in" % ('Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated')] + add_update_strs[1] += " Anki" + ## Evernote Status if self.DownloadSuccess: - line = "All %d" % self.Max + line = "All %3d" % self.Max else: - line = "%d of %d" % (self.Count, self.Max) + line = "%3d of %3d" % (self.Count, self.Max) lines = [line + " %s Evernote Metadata Results Were Successfully Downloaded%s." % ( add_update_strs[0], (' And %s' % add_update_strs[1]) if self.AnkiSuccess else '')] if self.Status.IsError: - lines.append("An error occurred during download (%s)." % str(self.Status)) + lines.append("-An error occurred during download (%s)." % str(self.Status)) + + ## Local Calls + if self.LocalDownloadsOccurred: + lines.append( + "-%d %s note(s) were unexpectedly found in the local db and did not require an API call." % (self.Local, add_update_strs[0])) + lines.append("-%d %s note(s) required an API call" % (self.Remote, add_update_strs[0])) + if not self.ImportType == EvernoteImportType.Add and self.AlreadyUpToDate > 0: + lines.append( + "-%3d existing note(s) are already up-to-date with Evernote's servers, so they were not retrieved." % self.AlreadyUpToDate) + + ## Anki Status if self.DownloadSuccess: return lines if self.AnkiSuccess: - line = "All %d" % self.Imported + line = "All %3d" % self.Imported else: - line = "%d of %d" % (self.Imported, self.Count) + line = "%3d of %3d" % (self.Imported, self.Count) lines.append(line + " %s Downloaded Evernote Notes Have Been Successfully %s." % ( add_update_strs[0], add_update_strs[1])) - if self.LocalDownloadsOccurred: - lines.append( - "-%d %s note(s) were unexpectedly found in the local db and did not require an API call." % self.Local) - lines.append("-%d %s note(s) required an API call" % self.Remote) - if not self.ImportType == EvernoteImportType.Add and self.AlreadyUpToDate > 0: - lines.append( - "%d existing note(s) are already up-to-date with Evernote's servers, so they were not retrieved." % self.AlreadyUpToDate) return lines @@ -268,6 +270,9 @@ def __init__(self, status=None, local=None): self.Local = local self.Imported = 0 self.Notes = [] + """ + :type : list[EvernoteNotePrototype] + """ def reportResult(self, result): """ @@ -276,7 +281,7 @@ def reportResult(self, result): self.Status = result.Status if self.Status == EvernoteAPIStatus.Success: self.Notes.append(result.Note) - if self.Source == 1: + if result.Source == 1: self.Local += 1 @@ -465,12 +470,8 @@ def List(self): def ListPadded(self): lst = [] for val in self.List: - pad = 20 - len(val) - padl = int(round(pad / 2)) - padr = padl - if padl + padr > pad: padr -= 1 - val = ' ' * padl + val + ' ' * padr - lst.append(val) + + lst.append(pad_center(val, 25)) return lst @property diff --git a/anknotes/toc.py b/anknotes/toc.py index 7cd9bfc..2704d81 100644 --- a/anknotes/toc.py +++ b/anknotes/toc.py @@ -136,7 +136,7 @@ def addHierarchy(self, tocHierarchy): tocTestBase = tocTestBase[2:] print " \nAdd Hierarchy: %-70s --> %-40s\n-------------------------------------" % ( - self.Title.FullTitle, tocTestBase) + self.Title.FullTitle, tocTestBase) if selfLevel > tocHierarchy.Title.Level: print "New Title Level is Below current level" @@ -172,34 +172,34 @@ def addHierarchy(self, tocHierarchy): assert (isinstance(tocChild, TOCHierarchyClass)) if tocChild.Title.TOCName == tocNewSelfChildTOCName: print "%-60s Child %-20s Match Succeeded for %s." % ( - self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) success = tocChild.addHierarchy(tocHierarchy) if success: return True print "%-60s Child %-20s Match Succeeded for %s: However, unable to add to matched child" % ( - self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) print "%-60s Child %-20s Search failed for %s" % ( - self.Title.FullTitle + ':', tocNewSelfChild.Name, tocNewChildNamesTitle) + self.Title.FullTitle + ':', tocNewSelfChild.Name, tocNewChildNamesTitle) newChild = tocHierarchy if isDirectChild else TOCHierarchyClass(tocNewSelfChild) newChild.parent = self if isDirectChild: print "%-60s Child %-20s Created Direct Child for %s." % ( - self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) success = True else: print "%-60s Child %-20s Created Title-Only Child for %-40ss." % ( - self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) success = newChild.addHierarchy(tocHierarchy) print "%-60s Child %-20s Created Title-Only Child for %-40s: Match %s." % ( - self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle, - "succeeded" if success else "failed") + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle, + "succeeded" if success else "failed") self.__isSorted__ = False self.Children.append(newChild) print "%-60s Child %-20s Appended Child for %s. Operation was an overall %s." % ( - self.Title.FullTitle + ':', newChild.Title.Name + ':', tocNewChildNamesTitle, - "success" if success else "failure") + self.Title.FullTitle + ':', newChild.Title.Name + ':', tocNewChildNamesTitle, + "success" if success else "failure") return success def sortChildren(self): @@ -322,8 +322,6 @@ def __init__(self, title=None, note=None, number=1): self.Children = [] self.__isSorted__ = False - - # # tocTest = TOCHierarchyClass("My Root Title") # tocTest.addTitle("My Root Title: Somebody") diff --git a/anknotes_standAlone.py b/anknotes_standAlone.py index 059be9d..e22010a 100644 --- a/anknotes_standAlone.py +++ b/anknotes_standAlone.py @@ -116,6 +116,5 @@ timerFull.stop() log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) - db.commit() db.close() \ No newline at end of file diff --git a/find_deleted_notes.py b/find_deleted_notes.py new file mode 100644 index 0000000..97a8fb8 --- /dev/null +++ b/find_deleted_notes.py @@ -0,0 +1 @@ +from anknotes import find_deleted_notes \ No newline at end of file From 7c594124bb6e05ed31d8b4e68741fb038ebf148c Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Thu, 17 Sep 2015 22:43:57 -0400 Subject: [PATCH 05/70] Finalized Note validation and Note maintenance ('Find deleted notes') --- anknotes/Anki.py | 15 +- anknotes/AnkiNotePrototype.py | 30 --- anknotes/Controller.py | 17 +- anknotes/EvernoteImporter.py | 11 +- anknotes/EvernoteNoteFetcher.py | 6 +- anknotes/EvernoteNotePrototype.py | 11 +- anknotes/EvernoteNoteTitle.py | 4 +- anknotes/EvernoteNotes.py | 3 +- anknotes/__main__.py | 5 +- anknotes/ankEvernote.py | 3 +- anknotes/constants.py | 12 +- anknotes/db.py | 4 +- anknotes/extra/ancillary/QMessageBox.css | 9 + anknotes/find_deleted_notes.py | 181 ++++++++++++------ anknotes/html.py | 11 +- anknotes/logging.py | 117 ++++++++---- anknotes/menu.py | 190 ++++++++++++------- anknotes/shared.py | 18 +- anknotes/stopwatch/__init__.py | 4 +- anknotes_standAlone.py | 26 +-- anknotes_start_find_deleted_notes.py | 9 + test.py => anknotes_start_note_validation.py | 0 find_deleted_notes.py | 1 - 23 files changed, 429 insertions(+), 258 deletions(-) create mode 100644 anknotes/extra/ancillary/QMessageBox.css create mode 100644 anknotes_start_find_deleted_notes.py rename test.py => anknotes_start_note_validation.py (100%) delete mode 100644 find_deleted_notes.py diff --git a/anknotes/Anki.py b/anknotes/Anki.py index 4dc3615..66a90d8 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -248,7 +248,9 @@ def get_anki_fields_from_anki_note_id(self, a_id, fields_to_ignore=list()): raise return get_dict_from_list(items, fields_to_ignore) - def get_evernote_guids_from_anki_note_ids(self, ids): + def get_evernote_guids_from_anki_note_ids(self, ids=None): + if ids is None: + ids = self.get_anknotes_note_ids() evernote_guids = [] self.usns = {} for a_id in ids: @@ -263,7 +265,9 @@ def get_evernote_guids_from_anki_note_ids(self, ids): log(" ! get_evernote_guids_from_anki_note_ids: Note '%s' is missing USN!" % evernote_guid) return evernote_guids - def get_evernote_guids_and_anki_fields_from_anki_note_ids(self, ids): + def get_evernote_guids_and_anki_fields_from_anki_note_ids(self, ids=None): + if ids is None: + ids = self.get_anknotes_note_ids() evernote_guids = {} for a_id in ids: fields = self.get_anki_fields_from_anki_note_id(a_id) @@ -321,7 +325,7 @@ def process_see_also_content(self, anki_note_ids): if anki_note_prototype.Fields[FIELDS.SEE_ALSO]: log("Detected see also contents for Note '%s': %s" % ( get_evernote_guid_from_anki_fields(fields), fields[FIELDS.TITLE])) - log(u" → %s " % strip_tags_and_new_lines(fields[FIELDS.SEE_ALSO])) + log(u" ::: %s " % strip_tags_and_new_lines(fields[FIELDS.SEE_ALSO])) if anki_note_prototype.update_note(): count_update += 1 count += 1 @@ -341,7 +345,7 @@ def insert_toc_into_see_also(self): grouped_results = {} # log(' INSERT TOCS INTO ANKI NOTES ', 'dump-insert_toc', timestamp=False, clear=True) # log('------------------------------------------------', 'dump-insert_toc', timestamp=False) - log(' <h1>INSERT TOCS INTO ANKI NOTES</h1> <HR><BR><BR>', 'see_also', timestamp=False, clear=True, + log(' <h1>INSERT TOC LINKS INTO ANKI NOTES</h1> <HR><BR><BR>', 'see_also', timestamp=False, clear=True, extension='htm') toc_titles = {} for row in results: @@ -405,12 +409,11 @@ def insert_toc_into_see_also(self): see_also_new = see_also_toc_header_ul + u'%s\n</ul>' % see_also_new else: see_also_new = see_also_toc_header + u'%s\n</ol>' % see_also_new - # log('\n\nWould like to add the following to %s: \n-----------------------\n%s\n-----------------------\n%s\n' % (fields[FIELDS.TITLE], see_also_new, see_also_html), 'dump-insert_toc', timestamp=False) if see_also_count == 0: see_also_html = generate_evernote_span(u'See Also:', 'Links', 'See Also') see_also_html += see_also_new see_also_html = see_also_html.replace('<ol>', '<ol style="margin-top:3px;">') - log('<h3>%s</h3><BR>' % generate_evernote_span(fields[FIELDS.TITLE], 'Links', + log('<h3>%s</h3><br>' % generate_evernote_span(fields[FIELDS.TITLE], 'Links', 'TOC') + see_also_html + u'<HR>', 'see_also', timestamp=False, extension='htm') fields[FIELDS.SEE_ALSO] = see_also_html.replace('evernote:///', 'evernote://') diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index fa7f46d..bb5b356 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -200,7 +200,6 @@ def process_note_content(self): ################################### Step 6: Process "See Also: " Links see_also_match = regex_see_also().search(content) if see_also_match: - # log_dump(see_also_match.group('SeeAlso'), "-See Also match for Note '%s': %s" % (self.evernote_guid, self.fields[FIELDS.TITLE])) content = content.replace(see_also_match.group(0), see_also_match.group('Suffix')) see_also = see_also_match.group('SeeAlso') see_also_header = see_also_match.group('SeeAlsoHeader') @@ -244,33 +243,6 @@ def process_note(self): self.process_note_content() self.detect_note_model() - # def _changeCards(self, nids, oldModel, newModel, map): - # d = [] - # deleted = [] - # for (cid, ord) in mw.col.db.execute( - # "select id, ord from cards where nid in "+ids2str(nids)): - # # if the src model is a cloze, we ignore the map, as the gui - # # doesn't currently support mapping them - # if oldModel['type'] == MODEL_CLOZE: - # new = ord - # if newModel['type'] != MODEL_CLOZE: - # # if we're mapping to a regular note, we need to check if - # # the destination ord is valid - # if len(newModel['tmpls']) <= ord: - # new = None - # else: - # # mapping from a regular note, so the map should be valid - # new = map[ord] # Line 460 - # if new is not None: - # d.append(dict( - # cid=cid,new=new,u=mw.col.usn(),m=intTime())) - # else: - # deleted.append(cid) - # mw.col.db.executemany( - # "update cards set ord=:new,usn=:u,mod=:m where id=:cid", - # d) - # mw.col.remCards(deleted) - def update_note_model(self): modelNameNew = self.ModelName if not modelNameNew: return False @@ -291,8 +263,6 @@ def update_note_model(self): cmap = {0: None, 1: 0} else: cmap[1] = None - # log("Changing model:\n From: '%s' \n To: '%s'" % (modelNameOld, modelNameNew )) - # log("NID %d cmap- %s" % (self.note.id, str(cmap))) self.log_update("Changing model:\n From: '%s' \n To: '%s'" % (modelNameOld, modelNameNew)) # self.log_update("NID %d cmap- %s" % (self.note.id, str(cmap))) mm.change(modelOld, [self.note.id], modelNew, fmap, cmap) diff --git a/anknotes/Controller.py b/anknotes/Controller.py index 9cceaf8..1c7aa1f 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -88,7 +88,7 @@ def upload_validated_notes(self, automated=False): queries1 = [] queries2 = [] noteFetcher = EvernoteNoteFetcher() - SIMULATE = True + SIMULATE = False if len(dbRows) == 0: if not automated: show_report(" > Upload of Validated Notes Aborted", "No Qualifying Validated Notes Found") @@ -147,7 +147,7 @@ def upload_validated_notes(self, automated=False): ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.MAKE_NOTE_QUEUE, queries1) if len(queries2) > 0: ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.MAKE_NOTE_QUEUE, queries2) - log(queries1) + # log(queries1) ankDB().commit() return status, count, exist @@ -200,15 +200,20 @@ def create_auto_toc(self): evernote_guid = None noteBody = self.evernote.makeNoteBody(contents, encode=True) - noteBody2 = self.evernote.makeNoteBody(contents, encode=True) + noteBody2 = self.evernote.makeNoteBody(contents, encode=False) if old_values: evernote_guid, old_content = old_values - if old_content == noteBody or old_content == noteBody2: + if type(old_content) != type(noteBody2): + log([rootTitle, type(old_content), type(noteBody), type(noteBody2)], 'AutoTOC-Create-Diffs\\_') + raise UnicodeWarning + eq2 = (old_content == noteBody2) + + if eq2: count += 1 count_update_skipped += 1 continue - log(generate_diff(old_content, noteBody2), 'AutoTOC-Create-Diffs') - continue + log(generate_diff(old_content, noteBody2), 'AutoTOC-Create-Diffs\\'+rootTitle) + # continue if not ANKNOTES.UPLOAD_AUTO_TOC_NOTES or ( ANKNOTES.AUTO_TOC_NOTES_MAX > -1 and count_update + count_create >= ANKNOTES.AUTO_TOC_NOTES_MAX): continue diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index 2829ef4..0097df2 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -45,6 +45,7 @@ class EvernoteImporter: evernote = None """:type : Evernote""" updateExistingNotes = UpdateExistingNotes.UpdateNotesInPlace + ManualGUIDs = None @property def ManualMetadataMode(self): return (self.ManualGUIDs is not None and len(self.ManualGUIDs) > 0) @@ -65,9 +66,8 @@ def override_evernote_metadata(self): result.startIndex = self.MetadataProgress.Offset result.notes = [] """:type : list[NoteMetadata]""" - for i in range(self.MetadataProgress.Offset, self.MetadataProgress.Completed + 1): + for i in range(self.MetadataProgress.Offset, self.MetadataProgress.Completed): result.notes.append(NoteMetadata(guids[i])) - result.totalNotes = len(guids) self.MetadataProgress.loadResults(result) self.evernote.metadata = self.MetadataProgress.NotesMetadata return True @@ -83,7 +83,7 @@ def get_evernote_metadata(self): spec = NotesMetadataResultSpec(includeTitle=False, includeUpdated=False, includeUpdateSequenceNum=True, includeTagGuids=True, includeNotebookGuid=True) api_action_str = u'trying to search for note metadata' - log_api("findNotesMetadata", "[Offset: %d]: Query: '%s'" % (self.MetadataProgress.Offset, query)) + log_api("findNotesMetadata", "[Offset: %3d]: Query: '%s'" % (self.MetadataProgress.Offset, query)) try: result = self.evernote.noteStore.findNotesMetadata(self.evernote.token, evernote_filter, self.MetadataProgress.Offset, @@ -261,7 +261,8 @@ def proceed_autopage(self): show_report(" > Terminating Auto Paging", "All %d notes have been processed and forceAutoPage is True" % self.MetadataProgress.Total, delay=5) - self.auto_page_callback() + if self.auto_page_callback: + self.auto_page_callback() return True elif col.conf.get(EVERNOTE.PAGING_RESTART_WHEN_COMPLETE, True): restart = EVERNOTE.PAGING_RESTART_INTERVAL @@ -277,7 +278,7 @@ def proceed_autopage(self): else: # Paging still in progress self.currentPage = self.MetadataProgress.Page + 1 restart_title = " > Continuing Auto Paging" - restart_msg = "Page %d completed. <BR>%d notes remain. <BR>%d of %d notes have been processed" % ( + restart_msg = "Page %d completed<BR>%d notes remain<BR>%d of %d notes have been processed" % ( self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.Completed, self.MetadataProgress.Total) restart = 0 diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index 01950b7..cedc23b 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -55,7 +55,7 @@ def reportResult(self, status=None, note=None, source=None): def getNoteLocal(self): # Check Anknotes database for note - query = "SELECT guid, title, content, notebookGuid, tagNames, updateSequenceNum FROM %s WHERE guid = '%s'" % ( + query = "SELECT * FROM %s WHERE guid = '%s'" % ( TABLES.EVERNOTE.NOTES, self.evernote_guid) if self.UpdateSequenceNum() > -1: query += " AND `updateSequenceNum` = %d" % self.UpdateSequenceNum() @@ -63,7 +63,7 @@ def getNoteLocal(self): """:type : sqlite.Row""" if not db_note: return False if not self.use_local_db_only: - log(" > getNoteLocal: GUID: '%s': %-40s" % (self.evernote_guid, db_note['title']), 'api') + log(" > getNoteLocal: GUID: '%s': %-40s" % (self.evernote_guid, db_note['title']), 'api') assert db_note['guid'] == self.evernote_guid self.reportSuccess(EvernoteNotePrototype(db_note=db_note), 1) self.tagNames = self.result.Note.TagNames if self.keepEvernoteTags else [] @@ -79,6 +79,7 @@ def addNoteFromServerToDB(self, whole_note=None, tag_names=None): if tag_names: self.tagNames = tag_names title = self.whole_note.title + log('Adding %s: %s' % (self.whole_note.guid, title), 'ankDB') content = self.whole_note.content tag_names = u',' + u','.join(self.tagNames).decode('utf-8') + u',' if isinstance(title, str): @@ -103,6 +104,7 @@ def addNoteFromServerToDB(self, whole_note=None, tag_names=None): ankDB().execute(sql_query) sql_query = sql_query_header_history + sql_query_columns ankDB().execute(sql_query) + ankDB().commit() def getNoteRemoteAPICall(self): api_action_str = u'trying to retrieve a note. We will save the notes downloaded thus far.' diff --git a/anknotes/EvernoteNotePrototype.py b/anknotes/EvernoteNotePrototype.py index 8314260..b09aea6 100644 --- a/anknotes/EvernoteNotePrototype.py +++ b/anknotes/EvernoteNotePrototype.py @@ -1,7 +1,7 @@ from anknotes.EvernoteNoteTitle import EvernoteNoteTitle from anknotes.html import generate_evernote_url, generate_evernote_link, generate_evernote_link_by_level from anknotes.structs import upperFirst - +from anknotes.logging import log class EvernoteNotePrototype: ################## CLASS Note ################ @@ -35,7 +35,7 @@ def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid= """ :type whole_note: evernote.edam.type.ttypes.Note - :type db_note: sqlite.Row + :type db_note: sqlite3.dbapi2.Row """ self.Status = -1 @@ -49,10 +49,15 @@ def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid= return if db_note is not None: self.Title = EvernoteNoteTitle(db_note) + db_note_keys = db_note.keys() if isinstance(db_note['tagNames'], str): db_note['tagNames'] = unicode(db_note['tagNames'], 'utf-8') for key in ['content', 'guid', 'notebookGuid', 'updateSequenceNum', 'tagNames', 'tagGuids']: - setattr(self, upperFirst(key), db_note[key]) + if not key in db_note_keys: + log("Unable to find key %s in db note %s! \n%s" % (key, self.Title.FullTitle, db_note_keys)) + log("Values: \n\n" + str({k: db_note[k] for k in db_note_keys }), 'EvernoteNotePrototypeInit') + else: + setattr(self, upperFirst(key), db_note[key]) if isinstance(self.Content, str): self.Content = unicode(self.Content, 'utf-8') self.process_tags() diff --git a/anknotes/EvernoteNoteTitle.py b/anknotes/EvernoteNoteTitle.py index 3c389a2..84c1469 100644 --- a/anknotes/EvernoteNoteTitle.py +++ b/anknotes/EvernoteNoteTitle.py @@ -1,7 +1,7 @@ ### Anknotes Shared Imports from anknotes.shared import * - - +from sys import stderr +log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') def generateTOCTitle(title): title = EvernoteNoteTitle.titleObjectToString(title).upper() for chr in u'?????': diff --git a/anknotes/EvernoteNotes.py b/anknotes/EvernoteNotes.py index e90181d..b14a9ba 100644 --- a/anknotes/EvernoteNotes.py +++ b/anknotes/EvernoteNotes.py @@ -10,10 +10,11 @@ from sqlite3 import dbapi2 as sqlite ### Anknotes Imports -# from anknotes.shared import * +from anknotes.shared import * from anknotes.EvernoteNoteTitle import * from anknotes.EvernoteNotePrototype import EvernoteNotePrototype from anknotes.toc import TOCHierarchyClass +from anknotes.db import ankDB class EvernoteNoteProcessingFlags: diff --git a/anknotes/__main__.py b/anknotes/__main__.py index a3511eb..f811ffe 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -91,7 +91,8 @@ def anknotes_profile_loaded(): # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) # menu.see_also(3) # menu.see_also(4) - + # mw.progress.timer(20000, lambda : menu.find_deleted_notes(True), False) + menu.see_also([3,4]) pass @@ -104,4 +105,4 @@ def anknotes_onload(): anknotes_onload() -log("Anki Loaded", "load") +# log("Anki Loaded", "load") diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 636c5cb..7ea6c93 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -114,6 +114,7 @@ def validateNoteBody(self, noteBody, title="Note Body"): log("Loading ENML DTD", "lxml", timestamp=False, do_print=True) self.DTD = etree.DTD(ANKNOTES.ENML_DTD) log("DTD Loaded in %s\n" % str(timerInterval), "lxml", timestamp=False, do_print=True) + log(' '*7+' > Note Validation: ENML DTD Loaded in %s' % str(timerInterval)) timerInterval.stop() del timerInterval @@ -365,7 +366,7 @@ def check_notebooks_up_to_date(self): notebookGuid = note_metadata.notebookGuid if not notebookGuid: log_error(" > Notebook check: Unable to find notebook guid for '%s'. Returned '%s'. Metadata: %s" % ( - evernote_guid, str(notebookGuid), str(note_metadata))) + evernote_guid, str(notebookGuid), str(note_metadata)), crossPost=False) elif notebookGuid not in self.notebook_data: nb = EvernoteNotebook(fetch_guid=notebookGuid) if not nb.success: diff --git a/anknotes/constants.py b/anknotes/constants.py index 6c18840..7dac36d 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -5,6 +5,7 @@ class ANKNOTES: + FOLDER_ADDONS_ROOT = os.path.dirname(PATH) FOLDER_EXTRA = os.path.join(PATH, 'extra') FOLDER_ANCILLARY = os.path.join(FOLDER_EXTRA, 'ancillary') FOLDER_GRAPHICS = os.path.join(FOLDER_EXTRA, 'graphics') @@ -13,10 +14,17 @@ class ANKNOTES: LOG_BASE_NAME = 'anknotes' TEMPLATE_FRONT = os.path.join(FOLDER_ANCILLARY, 'FrontTemplate.htm') CSS = u'_AviAnkiCSS.css' + QT_CSS_QMESSAGEBOX = os.path.join(FOLDER_ANCILLARY, 'QMessageBox.css') ENML_DTD = os.path.join(FOLDER_ANCILLARY, 'enml2.dtd') TABLE_OF_CONTENTS_ENEX = os.path.join(FOLDER_TESTING, "Table of Contents.enex") - VALIDATION_SCRIPT = os.path.join(os.path.dirname(PATH), 'test.py') # anknotes-standAlone.py') - FIND_DELETED_NOTES_SCRIPT = os.path.join(os.path.dirname(PATH), 'find_deleted_notes.py') # anknotes-standAlone.py') + VALIDATION_SCRIPT = os.path.join(FOLDER_ADDONS_ROOT, 'anknotes_start_note_validation.py') # anknotes-standAlone.py') + FIND_DELETED_NOTES_SCRIPT = os.path.join(FOLDER_ADDONS_ROOT, 'anknotes_start_find_deleted_notes.py') # anknotes-standAlone.py') + LOG_FDN_ANKI_ORPHANS = 'Find Deleted Notes\\' + LOG_FDN_UNIMPORTED_EVERNOTE_NOTES = LOG_FDN_ANKI_ORPHANS + 'UnimportedEvernoteNotes' + LOG_FDN_ANKI_TITLE_MISMATCHES = LOG_FDN_ANKI_ORPHANS + 'AnkiTitleMismatches' + LOG_FDN_ANKNOTES_TITLE_MISMATCHES = LOG_FDN_ANKI_ORPHANS + 'AnknotesTitleMismatches' + LOG_FDN_ANKNOTES_ORPHANS = LOG_FDN_ANKI_ORPHANS + 'AnknotesOrphans' + LOG_FDN_ANKI_ORPHANS += 'AnkiOrphans' ICON_EVERNOTE_WEB = os.path.join(FOLDER_GRAPHICS, u'evernote_web.ico') IMAGE_EVERNOTE_WEB = ICON_EVERNOTE_WEB.replace('.ico', '.png') ICON_EVERNOTE_ARTCORE = os.path.join(FOLDER_GRAPHICS, u'evernote_artcore.ico') diff --git a/anknotes/db.py b/anknotes/db.py index 56bbf44..15ae2d2 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -24,9 +24,9 @@ def ankDBIsLocal(): return dbLocal -def ankDB(): +def ankDB(reset=False): global ankNotesDBInstance, dbLocal - if not ankNotesDBInstance: + if not ankNotesDBInstance or reset: if dbLocal: ankNotesDBInstance = ank_DB(os.path.join(PATH, '..\\..\\Evernote\\collection.anki2')) else: diff --git a/anknotes/extra/ancillary/QMessageBox.css b/anknotes/extra/ancillary/QMessageBox.css new file mode 100644 index 0000000..bde0646 --- /dev/null +++ b/anknotes/extra/ancillary/QMessageBox.css @@ -0,0 +1,9 @@ +table tr.tr0, table tr.tr0 td.t1, table tr.tr0 td.t2, table tr.tr0 td.t3 { background-color: green; background: rgb(105, 170, 53); height: 42px; font-size: 32px; cell-spacing: 0px; padding-top: 10px; padding-bottom: 10px; } + +tr.tr0 td { background-color: green; background: rgb(105, 170, 53); height: 42px; font-size: 32px; cell-spacing: 0px; } +a { color: rgb(105, 170, 53); font-weight:bold; } +a:hover { color: rgb(135, 187, 93); font-weight:bold; text-decoration: none; } +a:active { color: rgb(135, 187, 93); font-weight:bold; text-decoration: none; } +td.t1 { font-weight: bold; color: #bf0060; text-align: center; padding-left: 10px; padding-right: 10px; } +td.t2 { text-transform: uppercase; font-weight: bold; color: #0060bf; padding-left:20px; padding-right:20px; font-size: 18px; } +td.t3 { color: #666; font-size: 10px; } diff --git a/anknotes/find_deleted_notes.py b/anknotes/find_deleted_notes.py index bfc9866..548f94d 100644 --- a/anknotes/find_deleted_notes.py +++ b/anknotes/find_deleted_notes.py @@ -7,67 +7,122 @@ from anknotes.shared import * -Error = sqlite.Error -ankDBSetLocal() - -ENNotes = file(ANKNOTES.TABLE_OF_CONTENTS_ENEX, 'r').read() -# find = file(os.path.join(PATH, "powergrep-find.txt") , 'r').read().splitlines() -# replace = file(os.path.join(PATH, "powergrep-replace.txt") , 'r').read().replace('https://www.evernote.com/shard/s175/nl/19775535/' , '').splitlines() - -all_notes = ankDB().all("SELECT guid, title FROM %s " % TABLES.EVERNOTE.NOTES) -ankDB().close() -find_guids = {} - -log1='Find Deleted Notes\\MissingFromAnki' -log2='Find Deleted Notes\\MissingFromEvernote' -log3='Find Deleted Notes\\TitleMismatch' -log_banner(' FIND DELETED EVERNOTE NOTES: EVERNOTE NOTES MISSING FROM ANKI ', log1) -log_banner(' FIND DELETED EVERNOTE NOTES: ANKI NOTES DELETED FROM EVERNOTE ', log2) -log_banner(' FIND DELETED EVERNOTE NOTES: TITLE MISMATCHES ', log3) - -for line in all_notes: - # line = line.split('::: ') - # guid = line[0] - # title = line[1] - guid = line['guid'] - title = line['title'] - title = clean_title(title) - find_guids[guid] = title -mismatch=0 -missingfromanki=0 -for match in find_evernote_links(ENNotes): - guid = match.group('guid') - title = match.group('Title') - title = clean_title(title) - title_safe = str_safe(title) - if guid in find_guids: - find_title = find_guids[guid] - find_title_safe = str_safe(find_title) - if find_title_safe == title_safe: - del find_guids[guid] - else: - # print("Found guid match, title mismatch for %s: \n - %s\n - %s" % (guid, title_safe, find_title_safe)) - log_plain(guid + ': ' + title_safe, log3) - mismatch += 1 - else: +def do_find_deleted_notes(all_anki_notes=None): + """ + :param all_anki_notes: from Anki.get_evernote_guids_and_anki_fields_from_anki_note_ids() + :type : dict[str, dict[str, str]] + :return: + """ + + Error = sqlite.Error + + ENNotes = file(ANKNOTES.TABLE_OF_CONTENTS_ENEX, 'r').read() + # find = file(os.path.join(PATH, "powergrep-find.txt") , 'r').read().splitlines() + # replace = file(os.path.join(PATH, "powergrep-replace.txt") , 'r').read().replace('https://www.evernote.com/shard/s175/nl/19775535/' , '').splitlines() + + all_anknotes_notes = ankDB().all("SELECT guid, title FROM %s " % TABLES.EVERNOTE.NOTES) + find_guids = {} + log_banner(' FIND DELETED EVERNOTE NOTES: UNIMPORTED EVERNOTE NOTES ', ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES) + log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKI NOTES ', ANKNOTES.LOG_FDN_ANKI_ORPHANS) + log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKNOTES DB ENTRIES ', ANKNOTES.LOG_FDN_ANKI_ORPHANS) + log_banner(' FIND DELETED EVERNOTE NOTES: ANKNOTES TITLE MISMATCHES ', ANKNOTES.LOG_FDN_ANKNOTES_TITLE_MISMATCHES) + log_banner(' FIND DELETED EVERNOTE NOTES: ANKI TITLE MISMATCHES ', ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES) + anki_mismatch = 0 + for line in all_anknotes_notes: + guid = line['guid'] + title = line['title'] + title = clean_title(title) + title_safe = str_safe(title) + find_guids[guid] = title + if all_anki_notes: + if guid in all_anki_notes: + find_title = all_anki_notes[guid][FIELDS.TITLE] + find_title_safe = str_safe(find_title) + if find_title_safe == title_safe: + del all_anki_notes[guid] + else: + log_plain(guid + '::: ' + title, ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES) + anki_mismatch += 1 + mismatch = 0 + missing_evernote_notes = [] + for match in find_evernote_links(ENNotes): + guid = match.group('guid') + title = match.group('Title') + title = clean_title(title) title_safe = str_safe(title) - # print("COULD NOT FIND Anknotes database GUID for Evernote Server GUID %s: %s" % (guid, title_safe)) - log_plain(guid + ': ' + title_safe, log1) - missingfromanki += 1 - -dels = [] -for guid, title in find_guids.items(): - title_safe = str_safe(title) - log_plain(guid + ': ' + title_safe, log2) - dels.append(guid) -print "\nTotal %3d notes deleted from Evernote but still present in Anki" % len(dels) -print "Total %3d notes present in Evernote but not present in Anki" % missingfromanki -print "Total %3d title mismatches" % mismatch - -# confirm = raw_input("Please type in the total number of results (%d) to confirm deletion from the Anknotes DB. Note that the notes will not be deleted from Anknotes' Notes History database.\n >> " % len(dels)) -# -# if confirm == str(len(dels)): -# print "Confirmed!" -# ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, dels) -# ankDB().commit() -# + if guid in find_guids: + find_title = find_guids[guid] + find_title_safe = str_safe(find_title) + if find_title_safe == title_safe: + del find_guids[guid] + else: + log_plain(guid + '::: ' + title, ANKNOTES.LOG_FDN_ANKNOTES_TITLE_MISMATCHES) + mismatch += 1 + else: + log_plain(guid + '::: ' + title, ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES) + missing_evernote_notes.append(guid) + + anki_dels = [] + anknotes_dels = [] + if all_anki_notes: + for guid, fields in all_anki_notes.items(): + log_plain(guid + '::: ' + fields[FIELDS.TITLE], ANKNOTES.LOG_FDN_ANKI_ORPHANS) + anki_dels.append(guid) + for guid, title in find_guids.items(): + log_plain(guid + '::: ' + title, ANKNOTES.LOG_FDN_ANKNOTES_ORPHANS) + anknotes_dels.append(guid) + + logs = [ + ["Orphan Anknotes DB Note(s)", + + len(anknotes_dels), + ANKNOTES.LOG_FDN_ANKNOTES_ORPHANS, + "(not present in Evernote)" + + ], + + ["Orphan Anki Note(s)", + + len(anki_dels), + ANKNOTES.LOG_FDN_ANKI_ORPHANS, + "(not present in Anknotes DB)" + + ], + + ["Unimported Evernote Note(s)", + + len(missing_evernote_notes), + ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES, + "(not present in Anknotes DB" + + ], + + ["Anknotes DB Title Mismatches", + + mismatch, + ANKNOTES.LOG_FDN_ANKNOTES_TITLE_MISMATCHES + + ], + + ["Anki Title Mismatches", + + anki_mismatch, + ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES + + ] + ] + # '<tr class=tr{:d}><td class=t1>{val[0]}</td><td class=t2><a href="{fn}">{title}</a></td><td class=t3>{val[1]}</td></tr>'.format(i, title=log[0], val=log[1], fn=convert_filename_to_local_link(log[1][1])) + results = [ + [ + log[1], + log[0] if log[1] == 0 else '<a href="%s">%s</a>' % (get_log_full_path(log[2], True), log[0]), + log[3] if len(log) > 3 else '' + ] + for log in logs] + + # showInfo(str(results)) + + return { + "Summary": results, "AnknotesOrphans": anknotes_dels, "AnkiOrphans": anki_dels, + "MissingEvernoteNotes": missing_evernote_notes + } diff --git a/anknotes/html.py b/anknotes/html.py index bf9ba64..870d499 100644 --- a/anknotes/html.py +++ b/anknotes/html.py @@ -84,7 +84,6 @@ def generate_evernote_link(guid, title=None, value=None, escape=True): def generate_evernote_link_by_level(guid, title=None, value=None, escape=True): return generate_evernote_link_by_type(guid, title, 'Levels', value, escape=escape) - def generate_evernote_html_element_style_attribute(link_type, value, bold=True, group=None): global evernote_link_colors colors = None @@ -201,6 +200,16 @@ def get_evernote_account_ids(): enAccountIDs = EvernoteAccountIDs() return enAccountIDs +def tableify_column(column): + return str(column).replace('\n', '\n<BR>').replace(' ', '  ') + +def tableify_lines(rows, columns=None, tr_index_offset=0, return_html=True): + if columns is None: columns = [] + elif not isinstance(columns, list): columns = [columns] + trs = ['<tr class="tr%d">%s\n</tr>\n' % (i_row, ''.join(['\n <td class="td%d">%s</td>' % (i_col+1, tableify_column(column)) for i_col, column in enumerate(row if isinstance(row, list) else row.split('|'))])) for i_row, row in enumerate(columns + rows)] + if return_html: + return '<table>%s</table>' % ''.join(trs) + return trs class EvernoteAccountIDs: uid = '0' diff --git a/anknotes/logging.py b/anknotes/logging.py index d26c491..d2b3724 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -12,7 +12,7 @@ try: from aqt import mw from aqt.utils import tooltip - from aqt.qt import QMessageBox, QPushButton + from aqt.qt import QMessageBox, QPushButton, QSizePolicy, QSpacerItem, QGridLayout, QLayout except: pass @@ -44,10 +44,10 @@ def counts_as_str(count, max=None): def show_report(title, header, log_lines=[], delay=None, log_header_prefix = ' '*5): lines = [] - for line in header.split('<BR>') + ('<BR>'.join(log_lines).split('<BR>') if log_lines else []): + for line in ('<BR>'.join(header) if isinstance(header, list) else header).split('<BR>') + ('<BR>'.join(log_lines).split('<BR>') if log_lines else []): level = 0 while line and line[level] is '-': level += 1 - lines.append('\t'*level + ('\t- ' if lines else '') + line[level:]) + lines.append('\t'*level + ('\t\t- ' if lines else '') + line[level:]) if len(lines) > 1: lines[0] += ': ' log_text = '<BR>'.join(lines) show_tooltip(log_text.replace('\t', '  '), delay=delay) @@ -57,28 +57,53 @@ def show_report(title, header, log_lines=[], delay=None, log_header_prefix = ' ' log_blank() -def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0, cancelButton=False, richText=False): - global imgEvernoteWebMsgBox, icoEvernoteArtcore +def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0, cancelButton=False, richText=False, minHeight=None, minWidth=400, styleSheet=None, convertNewLines=True): + global imgEvernoteWebMsgBox, icoEvernoteArtcore, icoEvernoteWeb msgDefaultButton = QPushButton(icoEvernoteArtcore, "Okay!", mw) msgCancelButton = QPushButton(icoTomato, "No Thanks", mw) + if not styleSheet: + styleSheet = file(ANKNOTES.QT_CSS_QMESSAGEBOX, 'r').read() if not isinstance(message, str) and not isinstance(message, unicode): message = str(message) - if richText: textFormat = 1 - + if richText: + textFormat = 1 + message = message.replace('\n', '<BR>\n') + message = '<style>\n%s</style>\n\n%s' % (styleSheet, message) + global messageBox messageBox = QMessageBox() messageBox.addButton(msgDefaultButton, QMessageBox.AcceptRole) if cancelButton: - messageBox.addButton(msgCancelButton, QMessageBox.NoRole) + messageBox.addButton(msgCancelButton, QMessageBox.RejectRole) messageBox.setDefaultButton(msgDefaultButton) messageBox.setIconPixmap(imgEvernoteWebMsgBox) messageBox.setTextFormat(textFormat) + + # message = ' %s %s' % (styleSheet, message) + # log(message, replace_newline=False) + messageBox.setWindowIcon(icoEvernoteWeb) + messageBox.setWindowIconText("Anknotes") messageBox.setText(message) messageBox.setWindowTitle(title) - messageBox.exec_() - if not cancelButton: return True - if messageBox.clickedButton() == cancelButton or messageBox.clickedButton() == 0: + # if minHeight: + # messageBox.setMinimumHeight(minHeight) + # messageBox.setMinimumWidth(minWidth) + # + # messageBox.setFixedWidth(1000) + hSpacer = QSpacerItem(minWidth, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) + + layout = messageBox.layout() + """:type : QGridLayout """ + # layout.addItem(hSpacer, layout.rowCount() + 1, 0, 1, layout.columnCount()) + layout.addItem(hSpacer, layout.rowCount() + 1, 0, 1, layout.columnCount()) + # messageBox.setStyleSheet(styleSheet) + + + ret = messageBox.exec_() + if not cancelButton: + return True + if messageBox.clickedButton() == msgCancelButton or messageBox.clickedButton() == 0: return False return True @@ -122,6 +147,8 @@ def obj2log_simple(content): content = str(content) return content +def convert_filename_to_local_link(filename): + return 'file:///' + filename.replace("\\", "//") def log_blank(filename='', clear=False, extension='log'): log(timestamp=False, filename=filename, clear=clear, extension=extension) @@ -138,6 +165,17 @@ def log_banner(title, filename, length=80, append_newline=True): log("-" * length, filename, timestamp=False) if append_newline: log_blank(filename) +def get_log_full_path(filename='', extension='log', as_url_link=False): + if not filename: + filename = ANKNOTES.LOG_BASE_NAME + '.' + extension + else: + if filename[0] is '+': + filename = filename[1:] + filename = ANKNOTES.LOG_BASE_NAME + '-%s.%s' % (filename, extension) + full_path = os.path.join(ANKNOTES.FOLDER_LOGS, filename) + if as_url_link: return convert_filename_to_local_link(full_path) + return full_path + def log(content=None, filename='', prefix='', clear=False, timestamp=True, extension='log', replace_newline=None, do_print=False): if content is None: content = '' @@ -147,15 +185,11 @@ def log(content=None, filename='', prefix='', clear=False, timestamp=True, exten if content[0] == "!": content = content[1:] prefix = '\n' - if not filename: - filename = ANKNOTES.LOG_BASE_NAME + '.' + extension - else: - if filename[0] is '+': - filename = filename[1:] - summary = " ** CROSS-POST TO %s: " % filename + content - if len(summary) > 200: summary = summary[:200] - log(summary) - filename = ANKNOTES.LOG_BASE_NAME + '-%s.%s' % (filename, extension) + if filename and filename[0] is '+': + # filename = filename[1:] + summary = " ** CROSS-POST TO %s: " % filename[1:] + content + log(summary[:200]) + full_path = get_log_full_path(filename, extension) try: content = content.encode('utf-8') except Exception: @@ -167,7 +201,6 @@ def log(content=None, filename='', prefix='', clear=False, timestamp=True, exten st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) else: st = '' - full_path = os.path.join(ANKNOTES.FOLDER_LOGS, filename) if not os.path.exists(os.path.dirname(full_path)): os.mkdir(os.path.dirname(full_path)) with open(full_path, 'w+' if clear else 'a+') as fileLog: @@ -192,22 +225,15 @@ def print_dump(obj): print content -def log_dump(obj, title="Object", filename='', clear=False, timestamp=True): +def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, extension='.log'): content = pprint.pformat(obj, indent=4, width=80) - if not filename: - filename = ANKNOTES.LOG_BASE_NAME + '-dump.log' - else: - if filename[0] is '+': - filename = filename[1:] - # noinspection PyUnboundLocalVariable - summary = " ** CROSS-POST TO %s: " % filename + content - if len(summary) > 200: summary = summary[:200] - log(summary) - filename = ANKNOTES.LOG_BASE_NAME + '-dump-%s.log' % filename - try: - content = content.encode('ascii', 'ignore') - except Exception: - pass + try: content = content.decode('utf-8', 'ignore') + except Exception: pass + if filename and filename[0] is '+': + summary = " ** CROSS-POST TO %s: " % filename[1:] + content + log(summary[:200]) + filename = 'dump' + ('-%s' % filename) if filename else '' + full_path = get_log_full_path(filename, extension) st = '' if timestamp: st = datetime.now().strftime(ANKNOTES.DATE_FORMAT) @@ -218,14 +244,19 @@ def log_dump(obj, title="Object", filename='', clear=False, timestamp=True): else: prefix = " **** Dumping %s" % title log(prefix) - prefix += '\r\n' - content = prefix + content.replace(', ', ', \n ') - content = content.replace("': {", "': {\n ") - content = content.replace('\r', '\r ').replace('\n', - '\n ') - full_path = os.path.join(ANKNOTES.FOLDER_LOGS, filename) + if isinstance(content, str): content = unicode(content, 'utf-8') + + try: + prefix += '\r\n' + content = prefix + content.replace(', ', ', \n ') + content = content.replace("': {", "': {\n ") + content = content.replace('\r', '\r ').replace('\n', + '\n ') + except: + pass + if not os.path.exists(os.path.dirname(full_path)): os.mkdir(os.path.dirname(full_path)) with open(full_path, 'w+' if clear else 'a+') as fileLog: @@ -280,3 +311,5 @@ def get_api_call_count(): else: return count return count + +log('completed %s' % __name__, 'import') \ No newline at end of file diff --git a/anknotes/menu.py b/anknotes/menu.py index 30080d9..e936829 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -18,9 +18,10 @@ from aqt.qt import SIGNAL, QMenu, QAction from aqt import mw from aqt.utils import getText -from anki.storage import Collection +# from anki.storage import Collection DEBUG_RAISE_API_ERRORS = False +log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') # noinspection PyTypeChecker @@ -32,25 +33,26 @@ def anknotes_setup_menu(): ["&Enable Auto Import On Profile Load", {'action': anknotes_menu_auto_import_changed, 'checkable': True}], ["Note &Validation", [ - ["Validate &And Upload Pending Notes", lambda: validate_pending_notes], + ["Validate &And Upload Pending Notes", validate_pending_notes], ["SEPARATOR", None], ["&Validate Pending Notes", lambda: validate_pending_notes(True, False)], ["&Upload Validated Notes", upload_validated_notes] ] ], - ["Process &See Also Links [Power Users Only!]", + ["Process &See Also Footer Links [Power Users Only!]", [ ["Complete All &Steps", see_also], ["SEPARATOR", None], - ["Step &1: Process Notes Without See Also Field", lambda: see_also(1)], + ["Step &1: Process Anki Notes Without See Also Field", lambda: see_also(1)], ["Step &2: Extract Links from TOC", lambda: see_also(2)], ["Step &3: Create Auto TOC Evernote Notes", lambda: see_also(3)], ["Step &4: Validate and Upload Auto TOC Notes", lambda: see_also(4)], ["SEPARATOR", None], - ["Step &5: Insert TOC/Outline Links Into Evernote Notes", lambda: see_also(5)], - ["Step &6: Validate and Upload Modified Notes", lambda: see_also(6)], + ["Step &5: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(5)], + ["Step &6: Update See Also Footer In Evernote Notes", lambda: see_also(6)], + ["Step &7: Validate and Upload Modified Evernote Notes", lambda: see_also(7)], ["SEPARATOR", None], - ["Step &7: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(7)] + ["Step &8: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(8)] ] ], ["&Maintenance Tasks", @@ -59,7 +61,7 @@ def anknotes_setup_menu(): ["Res&ync with Local DB", resync_with_local_db], ["Update Evernote &Ancillary Data", update_ancillary_data] ] - ] + ] ] ] @@ -109,8 +111,8 @@ def anknotes_load_menu_settings(): def import_from_evernote_manual_metadata(guids=None): if not guids: - guids = find_evernote_guids(file(ANKNOTES.FOLDER_LOGS + 'anknotes-Find Deleted Notes\\MissingFromAnki.log', 'r').read()) - showInfo("Manually downloading %n Notes: \n%s" % (len(guids), str(guids))) + guids = find_evernote_guids(file(ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES, 'r').read()) + log("Manually downloading %d Notes" % len(guids)) controller = Controller() controller.evernote.initialize_note_store() controller.forceAutoPage = True @@ -118,6 +120,7 @@ def import_from_evernote_manual_metadata(guids=None): controller.ManualGUIDs = guids controller.proceed() + def import_from_evernote(auto_page_callback=None): controller = Controller() controller.evernote.initialize_note_store() @@ -135,78 +138,120 @@ def upload_validated_notes(automated=False): controller = Controller() controller.upload_validated_notes(automated) -def find_deleted_notes(): - showInfo("""In order for this to work, you must create a 'Table of Contents' Note using the Evernote desktop application. Include all notes that you want to sync with Anki. + +def find_deleted_notes(automated=False): + if not automated and False: + showInfo("""In order for this to work, you must create a 'Table of Contents' Note using the Evernote desktop application. Include all notes that you want to sync with Anki. Export this note to the following path: '%s'. Press Okay to save and close your Anki collection, open the command-line deleted notes detection tool, and then re-open your Anki collection. -Once the command line tool is done running, you will get a summary of the results, and will be prompted to delete Anki Orphan Notes or download Missing Evernote Notes""" % ANKNOTES.TABLE_OF_CONTENTS_ENEX) - - mw.col.close() - handle = Popen(ANKNOTES.FIND_DELETED_NOTES_SCRIPT, stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) - stdoutdata, stderrdata = handle.communicate() - info = ("ERROR: {%s}\n\n" % stderrdata) if stderrdata else '' - info += "Return data: \n%s" % stdoutdata - showInfo(info) - dels = file(os.path.join(ANKNOTES.FOLDER_LOGS, ANKNOTES.LOG_BASE_NAME + '-Find Deleted Notes\\MissingFromEvernote.log'), 'r').read() - guids = find_evernote_guids(dels) - count = len(guids) - mfile = os.path.join(ANKNOTES.FOLDER_LOGS, ANKNOTES.LOG_BASE_NAME + '-Find Deleted Notes\\MissingFromAnki.log') - missing = file(mfile, 'r').read() - missing_guids = find_evernote_guids(missing) - count_missing = len(missing_guids) - - showInfo("Completed: %s\n\n%s" + ('Press Okay and we will show you a prompt to confirm deletion of the %d orphan Anki notes' % count) if count > 0 else 'No Orpan Anki Notes Found', info[:1000]) - mw.col.reopen() - mw.col.load() - if count > 0: - code = getText("Please enter code 'ANKI_DEL_%d' to delete your orphan Anki note(s)" % count)[0] - if code is 'ANKI_DEL_%d' % count: - ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, [[x] for x in guids]) - ankDB().commit() - show_tooltip("Deleted all %d Orphan Anki Notes" % count, time_out=5000) - if count_missing > 0: - ret = showInfo("Would you like to import %d missing Evernote Notes?\n\n<a href='%s'>Click to view results</a>" % mfile, cancelButton=True, richText=True) +Once the command line tool is done running, you will get a summary of the results, and will be prompted to delete Anki Orphan Notes or download Missing Evernote Notes""" % ANKNOTES.TABLE_OF_CONTENTS_ENEX, + richText=True) + + # mw.col.save() + # if not automated: + # mw.unloadCollection() + # else: + # mw.col.close() + # handle = Popen(['python',ANKNOTES.FIND_DELETED_NOTES_SCRIPT], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) + # stdoutdata, stderrdata = handle.communicate() + # err = ("ERROR: {%s}\n\n" % stderrdata) if stderrdata else '' + # stdoutdata = re.sub(' +', ' ', stdoutdata) + from anknotes import find_deleted_notes + returnedData = find_deleted_notes.do_find_deleted_notes() + lines = returnedData['Summary'] + info = tableify_lines(lines, '#|Type|Info') + # info = '<table><tr class=tr0><td class=t1>#</td><td class=t2>Type</td><td class=t3></td></tr>%s</table>' % '\n'.join(lines) + # info = info.replace('\n', '\n<BR>').replace(' ', '    ') + anknotes_dels = returnedData['AnknotesOrphans'] + anknotes_dels_count = len(anknotes_dels) + anki_dels = returnedData['AnkiOrphans'] + anki_dels_count = len(anki_dels) + missing_evernote_notes = returnedData['MissingEvernoteNotes'] + missing_evernote_notes_count = len(missing_evernote_notes) + showInfo(info, richText=True, minWidth=600) + db_changed = False + if anknotes_dels_count > 0: + code = \ + getText("Please enter code 'ANKNOTES_DEL_%d' to delete your orphan Anknotes DB note(s)" % anknotes_dels_count)[ + 0] + if code == 'ANKNOTES_DEL_%d' % anknotes_dels_count: + ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, [[x] for x in anknotes_dels]) + ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", + [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anknotes_dels]) + db_changed = True + show_tooltip("Deleted all %d Orphan Anknotes DB Notes" % count, 5000, 3000) + if anki_dels_count > 0: + code = getText("Please enter code 'ANKI_DEL_%d' to delete your orphan Anki note(s)" % anki_dels_count)[0] + if code == 'ANKI_DEL_%d' % anki_dels_count: + ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", + [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anki_dels]) + db_changed = True + show_tooltip("Deleted all %d Orphan Anki Notes" % count, 5000, 3000) + if db_changed: + ankDB().commit() + if missing_evernote_notes_count > 0: + evernote_confirm = "Would you like to import %d missing Evernote Notes?<BR><BR><a href='%s'>Click to view results</a>" % ( + missing_evernote_notes_count, + convert_filename_to_local_link(get_log_full_path(ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES))) + ret = showInfo(evernote_confirm, cancelButton=True, richText=True) if ret: - show_tooltip("YES !") - # import_from_evernote_manual_metadata() - else: - show_tooltip("NO !") - - - + import_from_evernote_manual_metadata(missing_evernote_notes) -def validate_pending_notes(showAlerts=True, uploadAfterValidation=True): +def validate_pending_notes(showAlerts=True, uploadAfterValidation=True, callback=None): + mw.unloadCollection() if showAlerts: showInfo("""Press Okay to save and close your Anki collection, open the command-line note validation tool, and then re-open your Anki collection.%s Anki will be unresponsive until the validation tool completes. This will take at least 45 seconds. The tool's output will be shown. If it is truncated, you may view the full log in the anknotes addon folder at extra\\logs\\anknotes-MakeNoteQueue-*.log""" - % ' Any validated notes will be automatically uploaded once your Anki collection is reopened.' if uploadAfterValidation else '') - mw.col.close() - # mw.closeAllCollectionWindows() - handle = Popen(ANKNOTES.VALIDATION_SCRIPT, stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) + % ( + ' Any validated notes will be automatically uploaded once your Anki collection is reopened.' if uploadAfterValidation else '')) + handle = Popen(['python', ANKNOTES.VALIDATION_SCRIPT], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) stdoutdata, stderrdata = handle.communicate() - info = ("ERROR: {%s}\n\n" % stderrdata) if stderrdata else '' - info += "Return data: \n%s" % stdoutdata - if showAlerts: showInfo("Completed: %s\n\n%s" % ('Press Okay to begin uploading successfully validated notes to the Evernote Servers' if uploadAfterValidation else '', info[:1000])) + stdoutdata = re.sub(' +', ' ', stdoutdata) + info = ("ERROR: {%s}<HR>" % stderrdata) if stderrdata else '' + proceed = True + if showAlerts: + tds = [[str(count), '<a href="%s">VIEW %s VALIDATIONS LOG</a>' % (fn, key.upper())] for key, fn, count in [ + [key, get_log_full_path(key, as_url_link=True), int(re.search(r'CHECKING +(\d{1,3}) ' + key.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1))] + for key in ['Pending', 'Successful', 'Failed']] if count > 0] + info += tableify_lines(tds, '#|Result') + proceed = showInfo("Completed: %s<BR>%s" % ( + 'Press Okay to begin uploading successfully validated notes to the Evernote Servers' if uploadAfterValidation else '', + info), cancelButton=True) + + + # mw.col.reopen() + # mw.col.load() + + if callback is None and (uploadAfterValidation and proceed): + callback = upload_validated_notes + external_tool_callback_timer(callback) - mw.col.reopen() - mw.col.load() - if uploadAfterValidation: - upload_validated_notes() +def reopen_collection(callback=None): + # mw.setupProfile() + mw.loadCollection() + ankDB(True) + if callback: callback() -def see_also(steps=None): +def external_tool_callback_timer(callback=None): + mw.progress.timer(3000, lambda: reopen_collection(callback), False) + + +def see_also(steps=None, showAlerts=None, validationComplete=False): controller = Controller() if not steps: steps = range(1, 10) if isinstance(steps, int): steps = [steps] - showAlerts = (len(steps) == 1) + multipleSteps = (len(steps) > 1) + if showAlerts is None: showAlerts = not multipleSteps + remaining_steps=steps if 1 in steps: # Should be unnecessary once See Also algorithms are finalized log(" > See Also: Step 1: Processing Un Added See Also Notes") @@ -218,18 +263,31 @@ def see_also(steps=None): log(" > See Also: Step 3: Creating Auto TOC Evernote Notes") controller.create_auto_toc() if 4 in steps: - log(" > See Also: Step 4: Validate and Upload Auto TOC Notes") - validate_pending_notes(showAlerts) + if validationComplete: + log(" > See Also: Step 4: Validate and Upload Auto TOC Notes: Upload Validating Notes") + upload_validated_notes(multipleSteps) + else: + steps = [-4] if 5 in steps: - log(" > See Also: Step 5: Inserting TOC/Outline Links Into Evernote Notes") + log(" > See Also: Step 5: Inserting TOC/Outline Links Into Anki Notes") controller.anki.insert_toc_into_see_also() if 6 in steps: - log(" > See Also: Step 6: Validate and Upload Modified Notes") - validate_pending_notes(showAlerts) + log(" > See Also: Step 6: Update See Also Footer In Evernote Notes") if 7 in steps: - log(" > See Also: Step 7: Inserting TOC/Outline Contents Into Anki Notes") + if validationComplete: + log(" > See Also: Step 7: Validate and Upload Modified Notes: Upload Validating Notes") + upload_validated_notes(multipleSteps) + else: + steps = [-7] + if 8 in steps: + log(" > See Also: Step 8: Inserting TOC/Outline Contents Into Anki Notes") controller.anki.insert_toc_and_outline_contents_into_notes() + do_validation = steps[0]*-1 + if do_validation>0: + log(" > See Also: Step %d: Validate and Upload %s Notes: Validating Notes" % (do_validation, {4: 'Auto TOC', 7: 'Modified Evernote'}[do_validation])) + remaining_steps = remaining_steps[remaining_steps.index(do_validation)+validationComplete and 1 or 0:] + validate_pending_notes(showAlerts, callback=lambda: see_also(remaining_steps, False, True)) def update_ancillary_data(): controller = Controller() diff --git a/anknotes/shared.py b/anknotes/shared.py index 89bf266..7d37b2c 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -7,9 +7,11 @@ ### Anknotes Imports from anknotes.constants import * +from anknotes.logging import * from anknotes.structs import * from anknotes.db import * -from anknotes.logging import * + +log('strting %s' % __name__, 'import') from anknotes.html import * ### Anki and Evernote Imports @@ -21,10 +23,7 @@ EDAMNotFoundException except: pass - - -# __all__ = ['ankDB'] - +log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') def get_friendly_interval_string(lastImport): if not lastImport: return "" td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) @@ -65,16 +64,19 @@ def get_tag_names_to_import(tagNames, evernoteTags=None, evernoteTagsToDelete=No key=lambda s: s.lower()) def find_evernote_guids(content): - return [x.group('guid') for x in re.finditer(r'(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)', content)] + return [x.group('guid') for x in re.finditer(r'\b(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b', content)] def find_evernote_links_as_guids(content): return [x.group('guid') for x in find_evernote_links(content)] +def replace_evernote_web_links(content): + return re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', + r'evernote:///view/\2/\1/\3/\3/', content) def find_evernote_links(content): # .NET regex saved to regex.txt as 'Finding Evernote Links' - regex_str = r'<a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?|' \ - r'https://www.evernote.com/shard/(?P<shard>s\d+)/[\w\d]+/(?P<uid>[\d]+?)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))"(?:[^>]*)?(?: style="[^\"].+?")?(?: shape="rect")?>(?P<Title>.+?)</a>' + content = replace_evernote_web_links(content) + regex_str = r'<a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)"(?:[^>]+)?>(?P<Title>.+?)</a>' ids = get_evernote_account_ids() if not ids.valid: match = re.search(regex_str, content) diff --git a/anknotes/stopwatch/__init__.py b/anknotes/stopwatch/__init__.py index eb0584d..4d0c41e 100644 --- a/anknotes/stopwatch/__init__.py +++ b/anknotes/stopwatch/__init__.py @@ -7,7 +7,7 @@ # you should have received as part of this distribution. import time -from anknotes.logging import log +# from anknotes.logging import log """stopwatch is a very simple Python module for measuring time. Great for finding out how long code takes to execute. @@ -118,7 +118,7 @@ def new(*args, **kw): t = Timer() retval = func(*args, **kw) t.stop() - log('Function %s completed in %s' % (func.__name__, t), "clockit") + # log('Function %s completed in %s' % (func.__name__, t), "clockit") del t return retval diff --git a/anknotes_standAlone.py b/anknotes_standAlone.py index e22010a..5f7b941 100644 --- a/anknotes_standAlone.py +++ b/anknotes_standAlone.py @@ -49,37 +49,37 @@ success_queued_items = db.all("SELECT * FROM %s WHERE validation_status = -1 " % TABLES.MAKE_NOTE_QUEUE) currentLog = 'Successful' - log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True, clear=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) log(" CHECKING %3d SUCCESSFUL MAKE NOTE QUEUE ITEMS " % len(success_queued_items), 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) for result in success_queued_items: line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' line += result['title'] - log(line, 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=False) + log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=False) currentLog = 'Failed' - log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True, clear=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) log(" CHECKING %3d FAILED MAKE NOTE QUEUE ITEMS " % len(failed_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) for result in failed_queued_items: line = '%-60s ' % (result['title'] + ':') line += (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW" line += result['validation_result'] - log(line, 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) - log("------------------------------------------------\n", 'MakeNoteQueue-'+currentLog, timestamp=False) - log(result['contents'], 'MakeNoteQueue-'+currentLog, timestamp=False) - log("------------------------------------------------\n", 'MakeNoteQueue-'+currentLog, timestamp=False) + log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) + log(result['contents'], 'MakeNoteQueue\\'+currentLog, timestamp=False) + log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) EN = Evernote() currentLog = 'Pending' - log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True, clear=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) log(" CHECKING %3d PENDING MAKE NOTE QUEUE ITEMS " % len(pending_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) timerFull = stopwatch.Timer() for result in pending_queued_items: @@ -99,7 +99,7 @@ # line += ' %-60s ' % (result['title'] + ':') if not success: errors = '\n * ' + '\n * '.join(errors) - log(line, 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) + log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) else: errors = '\n'.join(errors) @@ -114,7 +114,7 @@ timerFull.stop() - log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue-'+currentLog, timestamp=False, do_print=True) + log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) db.commit() db.close() \ No newline at end of file diff --git a/anknotes_start_find_deleted_notes.py b/anknotes_start_find_deleted_notes.py new file mode 100644 index 0000000..b731deb --- /dev/null +++ b/anknotes_start_find_deleted_notes.py @@ -0,0 +1,9 @@ +try: + from aqt.utils import getText + isAnki = True +except: + isAnki = False + +if not isAnki: + from anknotes import find_deleted_notes + find_deleted_notes.do_find_deleted_notes() \ No newline at end of file diff --git a/test.py b/anknotes_start_note_validation.py similarity index 100% rename from test.py rename to anknotes_start_note_validation.py diff --git a/find_deleted_notes.py b/find_deleted_notes.py deleted file mode 100644 index 97a8fb8..0000000 --- a/find_deleted_notes.py +++ /dev/null @@ -1 +0,0 @@ -from anknotes import find_deleted_notes \ No newline at end of file From 87b32073cd3c1cb8ebf6ae26d667f208bfcef7ec Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Sat, 19 Sep 2015 05:12:19 -0400 Subject: [PATCH 06/70] Class-based processing of See Also notes. This is to detect changes that need to be uploaded to Evernote --- anknotes/Anki.py | 14 +- anknotes/AnkiNotePrototype.py | 220 ++++++++----- anknotes/Controller.py | 24 +- anknotes/EvernoteNotePrototype.py | 10 +- anknotes/EvernoteNoteTitle.py | 9 +- anknotes/EvernoteNotes.py | 16 +- anknotes/_re.py | 277 ++++++++++++++++ anknotes/bare.py | 346 ++++++++++++++++---- anknotes/constants.py | 16 +- anknotes/db.py | 4 +- anknotes/enums.py | 108 ++++++ anknotes/extra/ancillary/QMessageBox.css | 18 +- anknotes/extra/ancillary/regex-see_also.txt | 11 +- anknotes/extra/ancillary/regex.txt | 8 +- anknotes/find_deleted_notes.py | 13 +- anknotes/html.py | 6 +- anknotes/logging.py | 241 ++++++++++++-- anknotes/menu.py | 46 +-- anknotes/shared.py | 15 +- anknotes/stopwatch/__init__.py | 195 +++++++++-- anknotes/structs.py | 280 ++++++++++++---- anknotes_standAlone.py | 6 +- anknotes_start_bare.py | 9 + 23 files changed, 1541 insertions(+), 351 deletions(-) create mode 100644 anknotes/_re.py create mode 100644 anknotes/enums.py create mode 100644 anknotes_start_bare.py diff --git a/anknotes/Anki.py b/anknotes/Anki.py index 66a90d8..b68f2a8 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -335,6 +335,10 @@ def process_toc_and_outlines(self): self.insert_toc_into_see_also() self.insert_toc_and_outline_contents_into_notes() + def update_evernote_note_contents(self): + see_also_notes = ankDB().all("SELECT DISTINCT target_evernote_guid FROM %s WHERE 1" % TABLES.SEE_ALSO) + + def insert_toc_into_see_also(self): db = ankDB() db._db.row_factory = None @@ -432,17 +436,15 @@ def extract_links_from_toc(self): delimiter = "" # link_exists = 0 for toc_evernote_guid, fields in toc_evernote_guids.items(): - for match in find_evernote_links(fields[FIELDS.CONTENT]): - target_evernote_guid = match.group('guid') - uid = int(match.group('uid')) - shard = match.group('shard') + for enLink in find_evernote_links(fields[FIELDS.CONTENT]): + target_evernote_guid = enLink.Guid # link_title = strip_tags(match.group('Title')) link_number = 1 + ankDB().scalar("select COUNT(*) from %s WHERE source_evernote_guid = '%s' " % ( TABLES.SEE_ALSO, target_evernote_guid)) toc_link_title = fields[FIELDS.TITLE] - toc_link_html = '<span style="color: rgb(173, 0, 0);"><b>%s</b></span>' % toc_link_title + toc_link_html = generate_evernote_span(toc_link_title, 'Links', 'TOC') query = """INSERT INTO `%s`(`source_evernote_guid`, `number`, `uid`, `shard`, `target_evernote_guid`, `html`, `title`, `from_toc`, `is_toc`) SELECT '%s', %d, %d, '%s', '%s', '%s', '%s', 1, 1 FROM `%s` WHERE NOT EXISTS (SELECT * FROM `%s` WHERE `source_evernote_guid`='%s' AND `target_evernote_guid`='%s') LIMIT 1 """ % ( - TABLES.SEE_ALSO, target_evernote_guid, link_number, uid, shard, toc_evernote_guid, + TABLES.SEE_ALSO, target_evernote_guid, link_number, enLink.Uid, enLink.Shard, toc_evernote_guid, toc_link_html.replace(u'\'', u'\'\''), toc_link_title.replace(u'\'', u'\'\''), TABLES.SEE_ALSO, TABLES.SEE_ALSO, target_evernote_guid, toc_evernote_guid) log_sql('UPDATE_ANKI_DB: Add See Also Link: SQL Query: ' + query) diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index bb5b356..a69a32d 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -16,7 +16,7 @@ def get_self_referential_fmap(): fmap = {} - for i in range(0, len(FIELDS_LIST)): + for i in range(0, len(FIELDS.LIST)): fmap[i] = i return fmap @@ -26,8 +26,8 @@ class AnkiNotePrototype: """:type : anknotes.Anki.Anki """ BaseNote = None """:type : AnkiNote """ - Title = None - """:type : EvernoteNoteTitle.EvernoteNoteTitle """ + enNote = None + """:type: EvernoteNotePrototype.EvernoteNotePrototype""" Fields = {} """:type : dict[str, str]""" Tags = [] @@ -47,11 +47,21 @@ class Counts: OriginalGuid = None """:type : str""" - Changed = False + Changed = False + _unprocessed_content_ = "" + _unprocessed_see_also_ = "" _log_update_if_unchanged_ = True - def __init__(self, anki, fields, tags, base_note=None, notebookGuid=None, count=-1, count_update=0, - max_count=1, counts=None): + @property + def Content(self): + return self.Fields[FIELDS.CONTENT] + + @Content.setter + def Content(self, value): + self.Fields[FIELDS.CONTENT] = value + + def __init__(self, anki=None, fields=None, tags=None, base_note=None, notebookGuid=None, count=-1, count_update=0, + max_count=1, counts=None, light_processing=False, enNote=None): """ Create Anki Note Prototype Class from fields or Base Anki Note :param anki: Anki: Anknotes Main Class Instance @@ -61,6 +71,8 @@ def __init__(self, anki, fields, tags, base_note=None, notebookGuid=None, count= :type tags : list[str] :param base_note: Base Anki Note if Updating an Existing Note :type base_note : anki.notes.Note + :param enNote: Base Evernote Note Prototype from Anknotes DB, usually used just to process a note's contents + :type enNote : EvernoteNotePrototype.EvernoteNotePrototype :param notebookGuid: :param count: :param count_update: @@ -69,10 +81,13 @@ def __init__(self, anki, fields, tags, base_note=None, notebookGuid=None, count= :type counts : AnkiNotePrototype.Counts :return: AnkiNotePrototype """ - + self.light_processing = light_processing self.Anki = anki self.Fields = fields self.BaseNote = base_note + if enNote and light_processing and not fields: + self.Fields = {FIELDS.TITLE: enNote.Title.FullTitle, FIELDS.CONTENT: enNote.Content, FIELDS.SEE_ALSO: u'', FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + enNote.Guid} + self.enNote = enNote self.Changed = False self.logged = False if counts: @@ -82,14 +97,14 @@ def __init__(self, anki, fields, tags, base_note=None, notebookGuid=None, count= self.Counts.Current = count + 1 self.Counts.Max = max_count self.initialize_fields() - self.Guid = get_evernote_guid_from_anki_fields(fields) + self.Guid = get_evernote_guid_from_anki_fields(self.Fields) self.NotebookGuid = notebookGuid self.ModelName = None # MODELS.EVERNOTE_DEFAULT - self.Title = EvernoteNoteTitle() - if not self.NotebookGuid: + # self.Title = EvernoteNoteTitle() + if not self.NotebookGuid and self.Anki: self.NotebookGuid = self.Anki.get_notebook_guid_from_ankdb(self.Guid) - assert self.Guid and self.NotebookGuid - self._deck_parent_ = self.Anki.deck + assert self.Guid and (self.light_processing or self.NotebookGuid) + self._deck_parent_ = self.Anki.deck if self.Anki else '' self.Tags = tags self.__cloze_count__ = 0 self.process_note() @@ -97,10 +112,10 @@ def __init__(self, anki, fields, tags, base_note=None, notebookGuid=None, count= def initialize_fields(self): if self.BaseNote: self.originalFields = get_dict_from_list(self.BaseNote.items()) - for field in FIELDS_LIST: + for field in FIELDS.LIST: if not field in self.Fields: self.Fields[field] = self.originalFields[field] if self.BaseNote else u'' - self.Title = EvernoteNoteTitle(self.Fields) + # self.Title = EvernoteNoteTitle(self.Fields) def deck(self): if EVERNOTE.TAG.TOC in self.Tags or EVERNOTE.TAG.AUTO_TOC in self.Tags: @@ -129,91 +144,111 @@ def process_note_see_also(self): return ankDB().execute("DELETE FROM %s WHERE source_evernote_guid = '%s' " % (TABLES.SEE_ALSO, self.Guid)) link_num = 0 - for match in find_evernote_links(self.Fields[FIELDS.SEE_ALSO]): + for enLink in find_evernote_links(self.Fields[FIELDS.SEE_ALSO]): link_num += 1 - title_text = strip_tags(match.group('Title')) + title_text = enLink.FullTitle is_toc = 1 if (title_text == "TOC") else 0 is_outline = 1 if (title_text is "O" or title_text is "Outline") else 0 ankDB().execute( "INSERT INTO %s (source_evernote_guid, number, uid, shard, target_evernote_guid, html, title, from_toc, is_toc, is_outline) VALUES('%s', %d, %d, '%s', '%s', '%s', '%s', 0, %d, %d)" % ( - TABLES.SEE_ALSO, self.Guid, link_num, int(match.group('uid')), match.group('shard'), - match.group('guid'), match.group('Title'), title_text, is_toc, is_outline)) + TABLES.SEE_ALSO, self.Guid, link_num, enLink.Uid, enLink.Shard, + enLink.Guid, enLink.HTML, title_text, is_toc, is_outline)) def process_note_content(self): - if not FIELDS.CONTENT in self.Fields: - return - content = self.Fields[FIELDS.CONTENT] - self.unprocessed_content = content - self.unprocessed_see_also = self.Fields[FIELDS.SEE_ALSO] - - ################################### Step 0: Correct weird Evernote formatting - remove_style_attrs = '-webkit-text-size-adjust: auto|-webkit-text-stroke-width: 0px|background-color: rgb(255, 255, 255)|color: rgb(0, 0, 0)|font-family: Tahoma|font-size: medium;|font-style: normal|font-variant: normal|font-weight: normal|letter-spacing: normal|orphans: 2|text-align: -webkit-auto|text-indent: 0px|text-transform: none|white-space: normal|widows: 2|word-spacing: 0px'.replace( - '(', '\\(').replace(')', '\\)') - # 'margin: 0px; padding: 0px 0px 0px 40px; ' - content = re.sub(r' ?(%s);? ?' % remove_style_attrs, '', content) - content = content.replace(' style=""', '') - - ################################### Step 1: Modify Evernote Links - # We need to modify Evernote's "Classic" Style Note Links due to an Anki bug with executing the evernote command with three forward slashes. - # For whatever reason, Anki cannot handle evernote links with three forward slashes, but *can* handle links with two forward slashes. - content = content.replace("evernote:///", "evernote://") - - # Modify Evernote's "New" Style Note links that point to the Evernote website. Normally these links open the note using Evernote's web client. - # The web client then opens the local Evernote executable. Modifying the links as below will skip this step and open the note directly using the local Evernote executable - content = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', - r'evernote://view/\2/\1/\3/\3/', content) - - ################################### Step 2: Modify Image Links - # Currently anknotes does not support rendering images embedded into an Evernote note. - # As a work around, this code will convert any link to an image on Dropbox, to an embedded <img> tag. - # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page - # Step 2.1: Modify HTML links to Dropbox images - dropbox_image_url_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))(?P<QueryString>(?:\?dl=(?:0|1))?)' - dropbox_image_src_subst = r'<a href="\g<URL>}\g<QueryString>}" shape="rect"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' - content = re.sub(r'<a href="%s".*?>(?P<Title>.+?)</a>' % dropbox_image_url_regex, - dropbox_image_src_subst % "'\g<Title>'", content) - - # Step 2.2: Modify Plain-text links to Dropbox images - try: - dropbox_image_url_regex = dropbox_image_url_regex.replace('(?P<QueryString>(?:\?dl=(?:0|1))?)', - '(?P<QueryString>\?dl=(?:0|1))') - content = re.sub(dropbox_image_url_regex, dropbox_image_src_subst % "From Plain-Text Link", content) - except: - log_error("\nERROR processing note, Step 2.2. Content: %s" % content) - - # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link)" - content = re.sub(r'<a href="(?P<URL>.+)"[^>]*>(?P<Title>\(Image Link.*\))</a>', - r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', - content) - - ################################### Step 3: Change white text to transparent - # I currently use white text in Evernote to display information that I want to be initially hidden, but visible when desired by selecting the white text. - # We will change the white text to a special "occluded" CSS class so it can be visible on the back of cards, and also so we can adjust the color for the front of cards when using night mode - content = content.replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') - - ################################### Step 4: Automatically Occlude Text in <<Double Angle Brackets>> - content = re.sub(r'<<(.+?)>>', r'<<<span class="occluded">$1</span>>>', content) - - ################################### Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. - content = re.sub(r'([^{]){(.+?)}([^}])', self.evernote_cloze_regex, content) - - ################################### Step 6: Process "See Also: " Links - see_also_match = regex_see_also().search(content) - if see_also_match: - content = content.replace(see_also_match.group(0), see_also_match.group('Suffix')) + + def step_0_remove_evernote_css_attributes(): + ################################### Step 0: Correct weird Evernote formatting + remove_style_attrs = '-webkit-text-size-adjust: auto|-webkit-text-stroke-width: 0px|background-color: rgb(255, 255, 255)|color: rgb(0, 0, 0)|font-family: Tahoma|font-size: medium;|font-style: normal|font-variant: normal|font-weight: normal|letter-spacing: normal|orphans: 2|text-align: -webkit-auto|text-indent: 0px|text-transform: none|white-space: normal|widows: 2|word-spacing: 0px'.replace( + '(', '\\(').replace(')', '\\)') + # 'margin: 0px; padding: 0px 0px 0px 40px; ' + self.Content = re.sub(r' ?(%s);? ?' % remove_style_attrs, '', self.Content) + self.Content = self.Content.replace(' style=""', '') + + def step_1_modify_evernote_links(): + ################################### Step 1: Modify Evernote Links + # We need to modify Evernote's "Classic" Style Note Links due to an Anki bug with executing the evernote command with three forward slashes. + # For whatever reason, Anki cannot handle evernote links with three forward slashes, but *can* handle links with two forward slashes. + self.Content = self.Content.replace("evernote:///", "evernote://") + + # Modify Evernote's "New" Style Note links that point to the Evernote website. Normally these links open the note using Evernote's web client. + # The web client then opens the local Evernote executable. Modifying the links as below will skip this step and open the note directly using the local Evernote executable + self.Content = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', + r'evernote://view/\2/\1/\3/\3/', self.Content) + + if self.light_processing: + self.Content = self.Content.replace("evernote://", "evernote:///") + + def step_2_modify_image_links(): + ################################### Step 2: Modify Image Links + # Currently anknotes does not support rendering images embedded into an Evernote note. + # As a work around, this code will convert any link to an image on Dropbox, to an embedded <img> tag. + # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page + # Step 2.1: Modify HTML links to Dropbox images + dropbox_image_url_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))(?P<QueryString>(?:\?dl=(?:0|1))?)' + dropbox_image_src_subst = r'<a href="\g<URL>}\g<QueryString>}" shape="rect"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' + self.Content = re.sub(r'<a href="%s".*?>(?P<Title>.+?)</a>' % dropbox_image_url_regex, + dropbox_image_src_subst % "'\g<Title>'", self.Content) + + # Step 2.2: Modify Plain-text links to Dropbox images + try: + dropbox_image_url_regex = dropbox_image_url_regex.replace('(?P<QueryString>(?:\?dl=(?:0|1))?)', + '(?P<QueryString>\?dl=(?:0|1))') + self.Content = re.sub(dropbox_image_url_regex, dropbox_image_src_subst % "From Plain-Text Link", self.Content) + except: + log_error("\nERROR processing note, Step 2.2. Content: %s" % self.Content) + + # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link)" + self.Content = re.sub(r'<a href="(?P<URL>.+)"[^>]*>(?P<Title>\(Image Link.*\))</a>', + r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', + self.Content) + + def step_3_occlude_text(): + ################################### Step 3: Change white text to transparent + # I currently use white text in Evernote to display information that I want to be initially hidden, but visible when desired by selecting the white text. + # We will change the white text to a special "occluded" CSS class so it can be visible on the back of cards, and also so we can adjust the color for the front of cards when using night mode + self.Content = self.Content.replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') + + ################################### Step 4: Automatically Occlude Text in <<Double Angle Brackets>> + self.Content = re.sub(r'<<(.+?)>>', r'<<<span class="occluded">$1</span>>>', self.Content) + + def step_5_create_cloze_fields(): + ################################### Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. + self.Content = re.sub(r'([^{]){(.+?)}([^}])', self.evernote_cloze_regex, self.Content) + + def step_6_process_see_also_links(): + ################################### Step 6: Process "See Also: " Links + see_also_match = regex_see_also().search(self.Content) + if not see_also_match: return + self.Content = self.Content.replace(see_also_match.group(0), see_also_match.group('Suffix')) see_also = see_also_match.group('SeeAlso') see_also_header = see_also_match.group('SeeAlsoHeader') see_also_header_stripme = see_also_match.group('SeeAlsoHeaderStripMe') if see_also_header_stripme: see_also = see_also.replace(see_also_header, see_also_header.replace(see_also_header_stripme, '')) if self.Fields[FIELDS.SEE_ALSO]: - self.Fields[FIELDS.SEE_ALSO] += "<BR><BR>\r\n" + self.Fields[FIELDS.SEE_ALSO] += "<br><br>\r\n" self.Fields[FIELDS.SEE_ALSO] += see_also + if self.light_processing: + self.Content = self.Content.replace(see_also_match.group('Suffix'), self.Fields[FIELDS.SEE_ALSO] + see_also_match.group('Suffix')) + return self.process_note_see_also() - - # TODO: Add support for extracting an 'Extra' field from the Evernote Note contents - ################################### Note Processing complete. - self.Fields[FIELDS.CONTENT] = content + if not FIELDS.CONTENT in self.Fields: + return + self._unprocessed_content_ = self.Content + self._unprocessed_see_also_ = self.Fields[FIELDS.SEE_ALSO] + steps = [0, 1, 6] if self.light_processing else range(0,7) + if self.light_processing and not ANKNOTES.NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING: + steps.remove(0) + if 0 in steps: step_0_remove_evernote_css_attributes() + step_1_modify_evernote_links() + if 2 in steps: + step_2_modify_image_links() + step_3_occlude_text() + step_5_create_cloze_fields() + step_6_process_see_also_links() + + # TODO: Add support for extracting an 'Extra' field from the Evernote Note contents + ################################### Note Processing complete. def detect_note_model(self): log('\nTitle, self.model_name, tags, self.model_name', 'detectnotemodel') @@ -241,7 +276,8 @@ def model_id(self): def process_note(self): self.process_note_content() - self.detect_note_model() + if not self.light_processing: + self.detect_note_model() def update_note_model(self): modelNameNew = self.ModelName @@ -365,8 +401,8 @@ def update_note_fields(self): raise if len(field_updates) == 2: if FIELDS.SEE_ALSO in fields_updated and FIELDS.CONTENT in fields_updated: - fc_test1 = (self.unprocessed_content == fields_updated[FIELDS.CONTENT]) - fc_test2 = (self.unprocessed_see_also == fields_updated[FIELDS.SEE_ALSO]) + fc_test1 = (self._unprocessed_content_ == fields_updated[FIELDS.CONTENT]) + fc_test2 = (self._unprocessed_see_also_ == fields_updated[FIELDS.SEE_ALSO]) fc_test = fc_test1 and fc_test2 if fc_test: field_updates = [] @@ -374,7 +410,7 @@ def update_note_fields(self): elif fc_test1: del field_updates[0] else: - log_dump([fc_test1, fc_test2, self.unprocessed_content, '-' + fields_updated[FIELDS.CONTENT]], + log_dump([fc_test1, fc_test2, self._unprocessed_content_, '-' + fields_updated[FIELDS.CONTENT]], 'AddUpdateNoteTest') for update in field_updates: self.log_update(update) @@ -420,11 +456,15 @@ def update_note(self): self.Counts.Updated += 1 return True + @property def Title(self): + """:rtype : EvernoteNoteTitle.EvernoteNoteTitle """ + title = "" if FIELDS.TITLE in self.Fields: - return self.Fields[FIELDS.TITLE] + title = self.Fields[FIELDS.TITLE] if self.BaseNote: - return self.originalFields[FIELDS.TITLE] + title = self.originalFields[FIELDS.TITLE] + return EvernoteNoteTitle(title) def add_note(self): self.create_note() diff --git a/anknotes/Controller.py b/anknotes/Controller.py index 1c7aa1f..6ab7d19 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -155,9 +155,9 @@ def upload_validated_notes(self, automated=False): def create_auto_toc(self): update_regex() self.anki.evernoteTags = [] - NoteDB = EvernoteNotes() - NoteDB.baseQuery = ANKNOTES.ROOT_TITLES_BASE_QUERY - dbRows = NoteDB.populateAllNonCustomRootNotes() + NotesDB = EvernoteNotes() + NotesDB.baseQuery = ANKNOTES.ROOT_TITLES_BASE_QUERY + dbRows = NotesDB.populateAllNonCustomRootNotes() # dbRows = NoteDB.populateAllPotentialRootNotes() number_updated = 0 number_created = 0 @@ -198,22 +198,22 @@ def create_auto_toc(self): "SELECT guid, content FROM %s WHERE UPPER(title) = ? AND tagNames LIKE '%%,' || ? || ',%%'" % TABLES.EVERNOTE.NOTES, rootTitle.upper(), EVERNOTE.TAG.AUTO_TOC) evernote_guid = None - noteBody = self.evernote.makeNoteBody(contents, encode=True) + # noteBody = self.evernote.makeNoteBody(contents, encode=True) - noteBody2 = self.evernote.makeNoteBody(contents, encode=False) + noteBodyUnencoded = self.evernote.makeNoteBody(contents, encode=False) if old_values: evernote_guid, old_content = old_values - if type(old_content) != type(noteBody2): - log([rootTitle, type(old_content), type(noteBody), type(noteBody2)], 'AutoTOC-Create-Diffs\\_') + if type(old_content) != type(noteBodyUnencoded): + log([rootTitle, type(old_content), type(noteBody)], 'AutoTOC-Create-Diffs\\_') raise UnicodeWarning - eq2 = (old_content == noteBody2) - - if eq2: + old_content = old_content.replace('guid-pending', evernote_guid) + noteBodyUnencoded = noteBodyUnencoded.replace('guid-pending', evernote_guid) + if old_content == noteBodyUnencoded: count += 1 count_update_skipped += 1 continue - log(generate_diff(old_content, noteBody2), 'AutoTOC-Create-Diffs\\'+rootTitle) - # continue + contents = contents.replace('/guid-pending/', '/%s/' % evernote_guid).replace('/guid-pending/', '/%s/' % evernote_guid) + log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create-Diffs\\'+rootTitle) if not ANKNOTES.UPLOAD_AUTO_TOC_NOTES or ( ANKNOTES.AUTO_TOC_NOTES_MAX > -1 and count_update + count_create >= ANKNOTES.AUTO_TOC_NOTES_MAX): continue diff --git a/anknotes/EvernoteNotePrototype.py b/anknotes/EvernoteNotePrototype.py index b09aea6..e2903bf 100644 --- a/anknotes/EvernoteNotePrototype.py +++ b/anknotes/EvernoteNotePrototype.py @@ -1,6 +1,6 @@ from anknotes.EvernoteNoteTitle import EvernoteNoteTitle from anknotes.html import generate_evernote_url, generate_evernote_link, generate_evernote_link_by_level -from anknotes.structs import upperFirst +from anknotes.structs import upperFirst, EvernoteAPIStatus from anknotes.logging import log class EvernoteNotePrototype: @@ -14,7 +14,8 @@ class EvernoteNotePrototype: TagNames = [] TagGuids = [] NotebookGuid = "" - Status = -1 + Status = EvernoteAPIStatus.Uninitialized + """:type : EvernoteAPIStatus """ Children = [] @property @@ -38,7 +39,7 @@ def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid= :type db_note: sqlite3.dbapi2.Row """ - self.Status = -1 + self.Status = EvernoteAPIStatus.Uninitialized self.TagNames = tags if whole_note is not None: self.Title = EvernoteNoteTitle(whole_note) @@ -46,6 +47,7 @@ def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid= self.Guid = whole_note.guid self.NotebookGuid = whole_note.notebookGuid self.UpdateSequenceNum = whole_note.updateSequenceNum + self.Status = EvernoteAPIStatus.Success return if db_note is not None: self.Title = EvernoteNoteTitle(db_note) @@ -61,12 +63,14 @@ def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid= if isinstance(self.Content, str): self.Content = unicode(self.Content, 'utf-8') self.process_tags() + self.Status = EvernoteAPIStatus.Success return self.Title = EvernoteNoteTitle(title) self.Content = content self.Guid = guid self.NotebookGuid = notebookGuid self.UpdateSequenceNum = updateSequenceNum + self.Status = EvernoteAPIStatus.Manual def generateURL(self): return generate_evernote_url(self.Guid) diff --git a/anknotes/EvernoteNoteTitle.py b/anknotes/EvernoteNoteTitle.py index 84c1469..8af34cd 100644 --- a/anknotes/EvernoteNoteTitle.py +++ b/anknotes/EvernoteNoteTitle.py @@ -1,7 +1,7 @@ ### Anknotes Shared Imports from anknotes.shared import * from sys import stderr -log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') +# log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') def generateTOCTitle(title): title = EvernoteNoteTitle.titleObjectToString(title).upper() for chr in u'?????': @@ -194,9 +194,14 @@ def FullTitle(self): """:rtype: str""" return self.__title__ + @property + def HTML(self): + return self.__html__ + def __init__(self, titleObj=None): """:type titleObj: str | unicode | sqlite.Row | EvernoteNoteTitle | evernote.edam.type.ttypes.Note | EvernoteNotePrototype.EvernoteNotePrototype """ - self.__title__ = self.titleObjectToString(titleObj) + self.__html__ = self.titleObjectToString(titleObj) + self.__title__ = strip_tags_and_new_lines(self.__html__) def generateTitleParts(title): diff --git a/anknotes/EvernoteNotes.py b/anknotes/EvernoteNotes.py index b14a9ba..c824e40 100644 --- a/anknotes/EvernoteNotes.py +++ b/anknotes/EvernoteNotes.py @@ -109,6 +109,12 @@ def addDbQuery(self, sql_query, order=''): @staticmethod def getNoteFromDB(query): + """ + + :param query: + :return: + :rtype : sqlite.Row + """ sql_query = "SELECT * FROM %s WHERE %s " % (TABLES.EVERNOTE.NOTES, query) dbNote = ankDB().first(sql_query) if not dbNote: return None @@ -118,6 +124,10 @@ def getNoteFromDBByGuid(self, guid): sql_query = "guid = '%s' " % guid return self.getNoteFromDB(sql_query) + def getEnNoteFromDBByGuid(self, guid): + return EvernoteNotePrototype(db_note=self.getNoteFromDBByGuid(guid)) + + # def addChildNoteHierarchically(self, enChildNotes, enChildNote): # parts = enChildNote.Title.TitleParts # dict_updated = {} @@ -286,7 +296,7 @@ def processAllRootNotesMissing(self): count_isolated = 0 # log (" CREATING TOC's " , 'tocList', clear=True, timestamp=False) # log ("------------------------------------------------" , 'tocList', timestamp=False) - # if DEBUG_HTML: log('<h1>CREATING TOCs</h1>', 'extra\\logs\\anknotes-toc-ols\\toc-index.htm', timestamp=False, clear=True, extension='htm') + # if DEBUG_HTML: log('<h1>CREATING TOCs</h1>', 'extra\\logs\\toc-ols\\toc-index.htm', timestamp=False, clear=True, extension='htm') ols = [] dbRows = [] returns = [] @@ -301,6 +311,8 @@ def processAllRootNotesMissing(self): tags = [] outline = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( escape_text_sql(rootTitleStr.upper()), EVERNOTE.TAG.OUTLINE)) + currentAutoNote = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( + escape_text_sql(rootTitleStr.upper()), EVERNOTE.TAG.AUTO_TOC)) notebookGuids = {} childGuid = None if total_child is 1 and not outline: @@ -376,7 +388,7 @@ def processAllRootNotesMissing(self): # log("Created TOC #%d:\n%s\n\n" % (count, strr), 'tocList', timestamp=False) if DEBUG_HTML: ols_html = u'\r\n<BR><BR><HR><BR><BR>\r\n'.join(ols) - fn = 'anknotes-toc-ols\\toc-index.htm' + fn = 'toc-ols\\toc-index.htm' file_object = open(os.path.join(ANKNOTES.FOLDER_LOGS, fn), 'w') try: file_object.write(u'<h1>CREATING TOCs</h1>\n\n' + ols_html) except: diff --git a/anknotes/_re.py b/anknotes/_re.py new file mode 100644 index 0000000..151ddc9 --- /dev/null +++ b/anknotes/_re.py @@ -0,0 +1,277 @@ +"""Skeleton for 're' stdlib module.""" + + +def compile(pattern, flags=0): + """Compile a regular expression pattern, returning a pattern object. + + :type pattern: bytes | unicode + :type flags: int + :rtype: __Regex + """ + pass + + +def search(pattern, string, flags=0): + """Scan through string looking for a match, and return a corresponding + match instance. Return None if no position in the string matches. + + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: __Match[T] | None + """ + pass + + +def match(pattern, string, flags=0): + """Matches zero or more characters at the beginning of the string. + + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: __Match[T] | None + """ + pass + + +def split(pattern, string, maxsplit=0, flags=0): + """Split string by the occurrences of pattern. + + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type maxsplit: int + :type flags: int + :rtype: list[T] + """ + pass + + +def findall(pattern, string, flags=0): + """Return a list of all non-overlapping matches of pattern in string. + + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: list[T] + """ + pass + + +def finditer(pattern, string, flags=0): + """Return an iterator over all non-overlapping matches for the pattern in + string. For each match, the iterator returns a match object. + + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: collections.Iterable[__Match[T]] + """ + pass + + +def sub(pattern, repl, string, count=0, flags=0): + """Return the string obtained by replacing the leftmost non-overlapping + occurrences of pattern in string by the replacement repl. + + :type pattern: bytes | unicode | __Regex + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :type flags: int + :rtype: T + """ + pass + + +def subn(pattern, repl, string, count=0, flags=0): + """Return the tuple (new_string, number_of_subs_made) found by replacing + the leftmost non-overlapping occurrences of pattern with the + replacement repl. + + :type pattern: bytes | unicode | __Regex + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :type flags: int + :rtype: (T, int) + """ + pass + + +def escape(string): + """Escape all the characters in pattern except ASCII letters and numbers. + + :type string: T <= bytes | unicode + :type: T + """ + pass + + +class __Regex(object): + """Mock class for a regular expression pattern object.""" + + def __init__(self, flags, groups, groupindex, pattern): + """Create a new pattern object. + + :type flags: int + :type groups: int + :type groupindex: dict[bytes | unicode, int] + :type pattern: bytes | unicode + """ + self.flags = flags + self.groups = groups + self.groupindex = groupindex + self.pattern = pattern + + def search(self, string, pos=0, endpos=-1): + """Scan through string looking for a match, and return a corresponding + match instance. Return None if no position in the string matches. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: __Match[T] | None + """ + pass + + def match(self, string, pos=0, endpos=-1): + """Matches zero | more characters at the beginning of the string. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: __Match[T] | None + """ + pass + + def split(self, string, maxsplit=0): + """Split string by the occurrences of pattern. + + :type string: T <= bytes | unicode + :type maxsplit: int + :rtype: list[T] + """ + pass + + def findall(self, string, pos=0, endpos=-1): + """Return a list of all non-overlapping matches of pattern in string. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: list[T] + """ + pass + + def finditer(self, string, pos=0, endpos=-1): + """Return an iterator over all non-overlapping matches for the + pattern in string. For each match, the iterator returns a + match object. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: collections.Iterable[__Match[T]] + """ + pass + + def sub(self, repl, string, count=0): + """Return the string obtained by replacing the leftmost non-overlapping + occurrences of pattern in string by the replacement repl. + + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :rtype: T + """ + pass + + def subn(self, repl, string, count=0): + """Return the tuple (new_string, number_of_subs_made) found by replacing + the leftmost non-overlapping occurrences of pattern with the + replacement repl. + + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :rtype: (T, int) + """ + pass + + +class __Match(object): + """Mock class for a match object.""" + + def __init__(self, pos, endpos, lastindex, lastgroup, re, string): + """Create a new match object. + + :type pos: int + :type endpos: int + :type lastindex: int | None + :type lastgroup: int | bytes | unicode | None + :type re: __Regex + :type string: bytes | unicode + :rtype: __Match[T] + """ + self.pos = pos + self.endpos = endpos + self.lastindex = lastindex + self.lastgroup = lastgroup + self.re = re + self.string = string + + def expand(self, template): + """Return the string obtained by doing backslash substitution on the + template string template. + + :type template: T + :rtype: T + """ + pass + + def group(self, *args): + """Return one or more subgroups of the match. + + :rtype: T | tuple + """ + pass + + def groups(self, default=None): + """Return a tuple containing all the subgroups of the match, from 1 up + to however many groups are in the pattern. + + :rtype: tuple + """ + pass + + def groupdict(self, default=None): + """Return a dictionary containing all the named subgroups of the match, + keyed by the subgroup name. + + :rtype: dict[bytes | unicode, T] + """ + pass + + def start(self, group=0): + """Return the index of the start of the substring matched by group. + + :type group: int | bytes | unicode + :rtype: int + """ + pass + + def end(self, group=0): + """Return the index of the end of the substring matched by group. + + :type group: int | bytes | unicode + :rtype: int + """ + pass + + def span(self, group=0): + """Return a 2-tuple (start, end) for the substring matched by group. + + :type group: int | bytes | unicode + :rtype: (int, int) + """ + pass diff --git a/anknotes/bare.py b/anknotes/bare.py index 22785c3..cc7c7dc 100644 --- a/anknotes/bare.py +++ b/anknotes/bare.py @@ -1,81 +1,285 @@ # -*- coding: utf-8 -*- -from EvernoteNotes import EvernoteNotes -from shared import * +import shutil try: from pysqlite2 import dbapi2 as sqlite except ImportError: from sqlite3 import dbapi2 as sqlite +from anknotes.shared import * +from anknotes import stopwatch +from anknotes.stopwatch import clockit +import re +from anknotes._re import __Match + +from anknotes.EvernoteNotes import EvernoteNotes +from anknotes.AnkiNotePrototype import AnkiNotePrototype +from enum import Enum +from anknotes.enums import * +from anknotes.structs import EvernoteAPIStatus + Error = sqlite.Error ankDBSetLocal() +NotesDB = EvernoteNotes() + + +class notes: + class version(object): + class pstrings: + __updated__ = None + __processed__ = None + __original__ = None + __regex_updated__ = None + """: type : notes.version.see_also_match """ + __regex_processed__ = None + """: type : notes.version.see_also_match """ + __regex_original__ = None + """: type : notes.version.see_also_match """ + + @property + def regex_original(self): + if self.original is None: return None + if self.__regex_original__ is None: + self.__regex_original__ = notes.version.see_also_match(self.original) + return self.__regex_original__ + + @property + def regex_processed(self): + if self.processed is None: return None + if self.__regex_processed__ is None: + self.__regex_processed__ = notes.version.see_also_match(self.processed) + return self.__regex_processed__ + + @property + def regex_updated(self): + if self.updated is None: return None + if self.__regex_updated__ is None: + self.__regex_updated__ = notes.version.see_also_match(self.updated) + return self.__regex_updated__ + + @property + def processed(self): + if self.__processed__ is None: + self.__processed__ = str_process(self.original) + return self.__processed__ + + @property + def updated(self): + if self.__updated__ is None: return self.__original__ + return self.__updated__ + + @updated.setter + def updated(self, value): + self.__regex_updated__ = None + self.__updated__ = value + + @property + def original(self): + return self.__original__ + + def useProcessed(self): + self.updated = self.processed + + def __init__(self, original=None): + self.__original__ = original + + class see_also_match(object): + __subject__ = None + __content__ = None + __matchobject__ = None + """:type : __Match """ + __match_attempted__ = 0 + + @property + def subject(self): + if not self.__subject__: return self.content + return self.__subject__ + + @subject.setter + def subject(self, value): + self.__subject__ = value + self.__match_attempted__ = 0 + self.__matchobject__ = None + + @property + def content(self): + return self.__content__ + + def groups(self, group=0): + """ + :param group: + :type group : int | str | unicode + :return: + """ + if not self.successful_match: + return None + return self.__matchobject__.group(group) + + @property + def successful_match(self): + if self.__matchobject__: return True + if self.__match_attempted__ is 0 and self.subject is not None: + self.__matchobject__ = notes.rgx.search(self.subject) + """:type : __Match """ + self.__match_attempted__ += 1 + return self.__matchobject__ is not None + + @property + def main(self): + return self.groups(0) + + @property + def see_also(self): + return self.groups('SeeAlso') + + @property + def see_also_content(self): + return self.groups('SeeAlsoContent') + + def __init__(self, content=None): + """ + + :type content: str | unicode + """ + self.__content__ = content + self.__match_attempted__ = 0 + self.__matchobject__ = None + """:type : __Match """ + + + content = pstrings() + see_also = pstrings() + + old = version() + new = version() + rgx = regex_see_also() + + +def str_process(strr): + strr = strr.replace(u"evernote:///", u"evernote://") + strr = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', + r'evernote://view/\2/\1/\3/\3/', strr) + strr = strr.replace(u"evernote://", u"evernote:///").replace(u'<BR>', u'<br />') + strr = re.sub(r'<br ?/?>', u'<br/>', strr, 0, re.IGNORECASE) + strr = re.sub(r'<<<span class="occluded">(.+?)</span>>>', r'<<\1>>', strr) + strr = strr.replace('<span class="occluded">', '<span style="color: rgb(255, 255, 255);">') + return strr + +def main_bare(): + @clockit + def print_results(): + diff = generate_diff(n.old.see_also.updated, n.new.see_also.updated) + log.plain(diff, 'SeeAlsoDiff\\Diff\\' + enNote.FullTitle, extension='htm', clear=True) + log.plain(n.old.see_also.updated, 'SeeAlsoDiff\\Original\\' + enNote.FullTitle, extension='htm', clear=True) + log.plain(n.new.see_also.updated, 'SeeAlsoDiff\\New\\' + enNote.FullTitle, extension='htm', clear=True) + log.plain(diff + '\r\n') + # diff = generate_diff(see_also_replace_old, see_also_replace_new) + # log_plain(diff, 'SeeAlsoDiff\\Diff\\' + enNote.FullTitle, clear=True) + # log_plain(see_also_replace_old, 'SeeAlsoDiff\\Original\\' + enNote.FullTitle, clear=True) + # log_plain(see_also_replace_new, 'SeeAlsoDiff\\New\\' + enNote.FullTitle, clear=True) + # log_plain(diff + '\r\n' , logall) + + @clockit + def process_note(): + n.old.content = notes.version.pstrings(enNote.Content) + # xx = n.old.content.original.match + # see_also_match_old = rgx.search(old_content) + # see_also_match_old = n.old.content.regex_original.__matchobject__ + # if not see_also_match_old: + if not n.old.content.regex_original.successful_match: + log.go("Could not get see also match for %s" % target_evernote_guid) + log.plain(enNote.Guid + '\r\n' + ', '.join(enNote.TagNames) + '\r\n' + n.old.content.original, 'SeeAlsoMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) + # new_content = old_content.replace('</en-note>', '<div><span><br/></span></div>' + n.new.see_also + '\n</en-note>') + n.new.content = notes.version.pstrings(n.old.content.original.replace('</en-note>', '<div><span><br/></span></div>' + n.new.see_also.original + '\n</en-note>')) + # see_also_replace_new = new_content + # see_also_replace_old = old_content + n.new.see_also.updated = n.new.content.original + n.old.see_also.updated = n.old.content.original + else: + # see_also_old = see_also_match_old.group(0) + # see_also_old = n.old.content.regex_original.main + n.old.see_also = notes.version.pstrings(n.old.content.regex_original.main) + # see_also_old_processed = str_process(see_also_old) + # see_also_old_processed = n.old.see_also.processed + + # see_also_match_old_processed = rgx.search(see_also_old_processed) + # see_also_match_old_processed = n.old.content.original.match.processed.__matchobject__ + # see_also_match_old_processed = n.old.see_also.regex_processed + # if n.old.content.original.match.processed.successful_match: + if n.old.see_also.regex_processed.successful_match: + # n.old.content.processed.content = n.old.content.original.subject.replace(n.old.content.original.match.original.subject, n.old.content.original.match.processed.subject) + assert True or str_process(n.old.content.regex_original.main) is n.old.content.regex_processed.main + n.old.content.updated = n.old.content.original.replace(n.old.content.regex_original.main, str_process(n.old.content.regex_original.main)) + # old_content = old_content.replace(see_also_old, see_also_old_processed) + # see_also_match_old = see_also_match_old_processed + n.old.see_also.useProcessed() + # see_also_match_old = n.old.see_also.updated + # xxx = n.old.content.original.match.processed + + # see_also_old_group_only = see_also_match_old.group('SeeAlso') + # see_also_old_group_only = n.old.content.original.match.processed.see_also.original.content + # see_also_old_group_only = n.old.see_also.regex_updated.see_also + # see_also_old_group_only_processed = str_process(see_also_old_group_only) + # see_also_old_group_only_processed = n.old.content.original.match.processed.see_also.processed.content + # see_also_old = str_process(see_also_match.group(0)) + n.new.see_also.regex_original.subject = n.new.see_also.original + '</en-note>' + # see_also_match_new = rgx.search(see_also_new + '</en-note>') + # if not see_also_match_new: + if not n.new.see_also.regex_original.successful_match: + log.go("Could not get see also new match for %s" % target_evernote_guid) + # log_plain(enNote.Guid + '\r\n' + ', '.join(enNote.TagNames) + '\r\n' + see_also_new, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, clear=True) + log.plain(enNote.Guid + '\r\n' + ', '.join(enNote.TagNames) + '\r\n' + n.new.see_also.original.content, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) + # see_also_replace_old = see_also_old_group_only_processed + see_also_replace_old = n.old.content.original.match.processed.see_also.processed.content + n.old.see_also.updated = n.old.content.regex_updated.see_also + # see_also_replace_new = see_also_new_processed + # see_also_replace_new = n.new.see_also.processed.content + n.new.see_also.updated = n.new.see_also.processed + else: + # see_also_replace_old = see_also_match_old.group('SeeAlsoContent') + # see_also_replace_old = n.old.content.original.match.processed.see_also_content + assert (n.old.content.regex_processed.see_also_content == notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content) + n.old.see_also.updated = notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content + # see_also_replace_new = see_also_match_new.group('SeeAlsoContent') + # see_also_replace_new = n.new.see_also.original.see_also_content + n.new.see_also.updated = n.new.see_also.regex_original.see_also_content + n.new.content.updated = n.old.content.updated.replace(n.old.see_also.updated, n.new.see_also.updated) + # new_content = old_content.replace(see_also_replace_old, see_also_replace_new) + # n.new.content = notes.version.pmatches() + + log = Logger(default_filename='SeeAlsoDiff\\__ALL', rm_path=True) + results = [x[0] for x in ankDB().all( + "SELECT DISTINCT target_evernote_guid FROM %s WHERE 1 ORDER BY title ASC " % TABLES.SEE_ALSO)] + changed = 0 + # rm_log_path(subfolders_only=True) + log.banner(" UPDATING EVERNOTE SEE ALSO CONTENT ", do_print=True) + + tmr = stopwatch.Timer(max=len(results), interval=25) + tmr.max = len(results) + for target_evernote_guid in results: + enNote = NotesDB.getEnNoteFromDBByGuid(target_evernote_guid) + n = notes() + if tmr.step(): + print "Note #%4s: %4s: %s" % (str(tmr.count), tmr.progress, + enNote.FullTitle if enNote.Status.IsSuccess else '(%s)' % target_evernote_guid) + if not enNote.Status.IsSuccess: + log.go("Could not get en note for %s" % target_evernote_guid) + continue + for tag in [EVERNOTE.TAG.TOC, EVERNOTE.TAG.OUTLINE]: + if tag in enNote.TagNames: break + else: + flds = ankDB().scalar("SELECT flds FROM notes WHERE flds LIKE '%%%s%s%%'" % ( + FIELDS.EVERNOTE_GUID_PREFIX, target_evernote_guid)) + n.new.see_also = notes.version.pstrings(flds.split("\x1f")[FIELDS.SEE_ALSO_FIELDS_ORD]) + process_note() + if n.old.see_also.updated == n.new.see_also.updated: continue + # if see_also_replace_old == see_also_replace_new: continue + print_results() + changed += 1 + enNote.Content = n.new.content.updated + + print "Total %d changed out of %d " % (changed, tmr.max) + + + -# from evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec -# from evernote.edam.type.ttypes import NoteSortOrder -# from evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode -# from evernote.api.client import EvernoteClient - -title = unicode( - ankDB().scalar("SELECT title FROM anknotes_evernote_notes WHERE guid = '13398462-7129-48bb-b13d-4139e324119a'")) -title_utf8 = title.encode('utf8') -# file_object = open('pytho2!n_intro.txt', 'w') -# file_object.write(title_utf8) -# file_object.close() - -# import sys -# import locale -# encoding = locale.getpreferredencoding() -# print encoding -# for x in range(945, 969): -# u = unichr(x) -# print(("%d %x "+"%s %s %s ") % (x, x, u.encode('utf-8'), repr(u.encode('utf-8')), repr(u.encode('cp737')))) - -# text=u'The \u03c0 number was known to Greeks.' -# full_path = u'test2-output.txt' -# with open(full_path , 'w+') as fileLog: -# print>>fileLog, text - -# title = unicode(ankDB().scalar("SELECT title FROM anknotes_evernote_notes WHERE guid = '13398462-7129-48bb-b13d-4139e324119a'")) -# title_utf8 = title.encode('utf8') -# file_object = open('pytho2!n_intro.txt', 'w') -# file_object.write(title_utf8) -# file_object.close() - -# full_path = u'test-output.txt' -# with open(full_path , 'w+') as fileLog: -# print>>fileLog, title_utf8 - -# if isinstance(title , str): -# title = unicode(title , 'utf-8') - -# # title_decode = title.decode('utf-8') - - -# file_object = open('pytho2n_intro.txt', 'w') -# file_object.write(text.encode('utf8')) -# file_object.close() - -# motto=u'''The Plato's Academy motto was: -# '\u1f00\u03b3\u03b5\u03c9\u03bc\u03ad\u03c4\u03c1\u03b7\u03c4\u03bf\u03c2 \u03bc\u03b7\u03b4\u03b5\u1f76\u03c2 \u03b5\u1f30\u03c3\u03af\u03c4\u03c9' -# ''' -# file_object = open('python_intro.txt', 'w') -# file_object.write(motto.encode('utf8')) -# file_object.close() - -# print(text) -# e_str = '\xc3\xa9' -# print(e_str) -# quit -# # full_path = u'test-output.txt' -# # with open(full_path , 'w+') as fileLog: -# # print>>fileLog, title_decode - - -# exit - -NoteDB = EvernoteNotes() -NoteDB.baseQuery = "notebookGuid != 'fdccbccf-ee70-4069-a587-82772a96d9d3' AND notebookGuid != 'faabcd80-918f-49ca-a349-77fd0036c051'" -# NoteDB.populateAllRootNotesWithoutTOCOrOutlineDesignation() -NoteDB.populateAllRootNotesMissing() -# enNote = NoteDB.getNoteFromDBByGuid('bb490d9c-722a-48f2-a678-6a14919dd3ea') -# NoteDB.addChildNoteHierarchically(dict, enNote) diff --git a/anknotes/constants.py b/anknotes/constants.py index 7dac36d..7bb9afb 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -11,7 +11,11 @@ class ANKNOTES: FOLDER_GRAPHICS = os.path.join(FOLDER_EXTRA, 'graphics') FOLDER_LOGS = os.path.join(FOLDER_EXTRA, 'logs') FOLDER_TESTING = os.path.join(FOLDER_EXTRA, 'testing') - LOG_BASE_NAME = 'anknotes' + LOG_BASE_NAME = '' + LOG_DEFAULT_NAME = 'anknotes' + LOG_MAIN = LOG_DEFAULT_NAME + LOG_ACTIVE = LOG_DEFAULT_NAME + LOG_USE_CALLER_NAME = False TEMPLATE_FRONT = os.path.join(FOLDER_ANCILLARY, 'FrontTemplate.htm') CSS = u'_AviAnkiCSS.css' QT_CSS_QMESSAGEBOX = os.path.join(FOLDER_ANCILLARY, 'QMessageBox.css') @@ -40,7 +44,7 @@ class ANKNOTES: ENABLE_VALIDATION = True AUTOMATE_VALIDATION = True ROOT_TITLES_BASE_QUERY = "notebookGuid != 'fdccbccf-ee70-4069-a587-82772a96d9d3'" - + NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING = False class MODELS: EVERNOTE_DEFAULT = 'evernote_note' @@ -56,7 +60,7 @@ class TEMPLATES: EVERNOTE_CLOZE = 'EvernoteReviewCloze' -class FIELDS: +class FIELDS: TITLE = 'Title' CONTENT = 'Content' SEE_ALSO = 'See_Also' @@ -66,10 +70,12 @@ class FIELDS: EVERNOTE_GUID = 'Evernote GUID' UPDATE_SEQUENCE_NUM = 'updateSequenceNum' EVERNOTE_GUID_PREFIX = 'evernote_guid=' + LIST = [TITLE, CONTENT, SEE_ALSO, EXTRA, TOC, OUTLINE, + UPDATE_SEQUENCE_NUM] + SEE_ALSO_FIELDS_ORD = LIST.index(SEE_ALSO) + 1 + -FIELDS_LIST = [FIELDS.TITLE, FIELDS.CONTENT, FIELDS.SEE_ALSO, FIELDS.EXTRA, FIELDS.TOC, FIELDS.OUTLINE, - FIELDS.UPDATE_SEQUENCE_NUM] class DECKS: diff --git a/anknotes/db.py b/anknotes/db.py index 15ae2d2..e3b0e9f 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -23,7 +23,6 @@ def ankDBIsLocal(): global dbLocal return dbLocal - def ankDB(reset=False): global ankNotesDBInstance, dbLocal if not ankNotesDBInstance or reset: @@ -73,6 +72,9 @@ def __init__(self, path=None, text=None, timeout=0): self.echo = os.environ.get("DBECHO") self.mod = False + def setrowfactory(self): + self._db.row_factory = sqlite.Row + def execute(self, sql, *a, **ka): s = sql.strip().lower() # mark modified? diff --git a/anknotes/enums.py b/anknotes/enums.py new file mode 100644 index 0000000..813e50d --- /dev/null +++ b/anknotes/enums.py @@ -0,0 +1,108 @@ +from anknotes.enum import Enum, EnumMeta, IntEnum +from anknotes import enum +class AutoNumber(Enum): + def __new__(cls, *args): + """ + + :param cls: + :return: + :rtype : AutoNumber + """ + value = len(cls.__members__) + 1 + if args and args[0]: value=args[0] + while value in cls._value2member_map_: value += 1 + obj = object.__new__(cls) + obj._id_ = value + obj._value_ = value + # if obj.name in obj._member_names_: + # raise KeyError + return obj +class OrderedEnum(Enum): + def __ge__(self, other): + if self.__class__ is other.__class__: + return self._value_ >= other._value_ + return NotImplemented + def __gt__(self, other): + if self.__class__ is other.__class__: + return self._value_ > other._value_ + return NotImplemented + def __le__(self, other): + if self.__class__ is other.__class__: + return self._value_ <= other._value_ + return NotImplemented + def __lt__(self, other): + if self.__class__ is other.__class__: + return self._value_ < other._value_ + return NotImplemented +class auto_enum(EnumMeta): + def __new__(metacls, cls, bases, classdict): + original_dict = classdict + classdict = enum._EnumDict() + for k, v in original_dict.items(): + classdict[k] = v + temp = type(classdict)() + names = set(classdict._member_names) + i = 0 + + for k in classdict._member_names: + v = classdict[k] + if v == () : + v = i + else: + i = max(v, i) + i += 1 + temp[k] = v + for k, v in classdict.items(): + if k not in names: + temp[k] = v + return super(auto_enum, metacls).__new__( + metacls, cls, bases, temp) + + def __ge__(self, other): + if self.__class__ is other.__class__: + return self._value_ >= other._value_ + return NotImplemented + def __gt__(self, other): + if self.__class__ is other.__class__: + return self._value_ > other._value_ + return NotImplemented + def __le__(self, other): + if self.__class__ is other.__class__: + return self._value_ <= other._value_ + return NotImplemented + def __lt__(self, other): + if self.__class__ is other.__class__: + return self._value_ < other._value_ + return NotImplemented + +AutoNumberedEnum = auto_enum('AutoNumberedEnum', (OrderedEnum,), {}) + +AutoIntEnum = auto_enum('AutoIntEnum', (IntEnum,), {}) + + + +class AutoIntEnum(AutoIntEnum): + def testVal(self): + return self.value + + def testName(self): + return self.name + + def testAll(self): + return self.name + ' doubled - ' + str(self.value * 2) + +class APIStatus(AutoIntEnum): + Val1=() + """:type : AutoIntEnum""" + Val2=() + """:type : AutoIntEnum""" + Val3=() + """:type : AutoIntEnum""" + Val4=() + """:type : AutoIntEnum""" + Val5=() + """:type : AutoIntEnum""" + Val6=() + """:type : AutoIntEnum""" + + Val1, Val2, Val3, Val4, Val5, Val6, Val7 = range(1, 8) diff --git a/anknotes/extra/ancillary/QMessageBox.css b/anknotes/extra/ancillary/QMessageBox.css index bde0646..0bbb0f8 100644 --- a/anknotes/extra/ancillary/QMessageBox.css +++ b/anknotes/extra/ancillary/QMessageBox.css @@ -1,9 +1,15 @@ -table tr.tr0, table tr.tr0 td.t1, table tr.tr0 td.t2, table tr.tr0 td.t3 { background-color: green; background: rgb(105, 170, 53); height: 42px; font-size: 32px; cell-spacing: 0px; padding-top: 10px; padding-bottom: 10px; } - -tr.tr0 td { background-color: green; background: rgb(105, 170, 53); height: 42px; font-size: 32px; cell-spacing: 0px; } +table tr.tr0, table tr.tr0 td.td1, table tr.tr0 td.td2, table tr.tr0 td.td3 { background: rgb(78, 124, 39); height: 42px; font-size: 32px; cell-spacing: 0px; padding-top: 1px; padding-bottom: 1px; } +table { border: 1px solid black; border-bottom: 10px solid black; } +table tr td { border: 1px solid black; } +tr.std { background: rgb(105, 170, 53); cell-spacing: 0px; } +tr.alt { background: rgb(135, 187, 93); cell-spacing: 0px; } +tr.tr0 { background: rgb(78, 124, 39); height: 42px; font-size: 32px; cell-spacing: 0px; } +tr.tr0 td.td2 { color: rgb(173, 0, 0); } +tr.tr0 td.td3 { color: #444; } a { color: rgb(105, 170, 53); font-weight:bold; } a:hover { color: rgb(135, 187, 93); font-weight:bold; text-decoration: none; } a:active { color: rgb(135, 187, 93); font-weight:bold; text-decoration: none; } -td.t1 { font-weight: bold; color: #bf0060; text-align: center; padding-left: 10px; padding-right: 10px; } -td.t2 { text-transform: uppercase; font-weight: bold; color: #0060bf; padding-left:20px; padding-right:20px; font-size: 18px; } -td.t3 { color: #666; font-size: 10px; } +table a { color: rgb(106, 0, 129);} +td.td1 { font-weight: bold; color: #bf0060; text-align: center; padding-left: 10px; padding-right: 10px; } +td.td2 { text-transform: uppercase; font-weight: bold; color: #0060bf; padding-left:20px; padding-right:20px; font-size: 18px; } +td.td3 { color: #666; font-size: 10px; } diff --git a/anknotes/extra/ancillary/regex-see_also.txt b/anknotes/extra/ancillary/regex-see_also.txt index e6d5e68..f2a0c80 100644 --- a/anknotes/extra/ancillary/regex-see_also.txt +++ b/anknotes/extra/ancillary/regex-see_also.txt @@ -1,13 +1,14 @@ (?P<PrefixStrip><div>(?:<b>)?<span[^>]*><br.?/?></span>(?:</b>)?</div>)? (?P<SeeAlso> - <div[^>]*> + (?P<SeeAlsoHeaderDiv><div[^>]*>) (?P<SeeAlsoHeader> - (?:<(?:span|b|font)[^>]*>){0,5} + (?P<SeeAlsoHeaderPrefix>(?:<(?:span|b|font)[^>]*>){0,5}) (?P<SeeAlsoHeaderStripMe><br />(?:\r|\n|\r\n)?)? See.[Aa]lso:?(?:\ | )? - (?:</(?:span|b|font)>){0,5} + (?P<SeeAlsoHeaderSuffix>(?:</(?:span|b|font)>){0,5}) + ) + (?P<SeeAlsoContent> + .+ # See Also Contents ) - - .+ # See Also Contents ) (?P<Suffix></en-note>) \ No newline at end of file diff --git a/anknotes/extra/ancillary/regex.txt b/anknotes/extra/ancillary/regex.txt index 56f2df2..e3f878e 100644 --- a/anknotes/extra/ancillary/regex.txt +++ b/anknotes/extra/ancillary/regex.txt @@ -3,7 +3,9 @@ Converting this file to Python: 2) (?< Finding Evernote Links - <a href="(?<URL>evernote:///?view/(?<uid>[\d]+?)/(?<shard>s\d+)/(?<guid>[\w\-]+?)/(?P=guid)/?)"(?: shape="rect")?(?: style="[^\"].+?")?(?: shape="rect")?>(?<Title>.+?)</a> + <a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)"(?:[^>]+)?>(?P<Title>.+?)</a> + https://www.evernote.com/shard/(?P<shard>s\d+)/[\w\d]+/(?P<uid>\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}) + Step 6: Process "See Also: " Links (?<PrefixStrip><div><b><span[^>]*><br/></span></b></div>)?(?: @@ -30,5 +32,5 @@ Step 6: Process "See Also: " Links )?)(?<SeeAlsoContents>.+))(?<Suffix></en-note>) Replace Python Parameters with Reference to Self - ([\w_]+)=[.+?](,|\) - $1=$1$2 \ No newline at end of file + ([\w_]+)(?: ?= ?(.+?))?(,|\)) + $1=$1$3 \ No newline at end of file diff --git a/anknotes/find_deleted_notes.py b/anknotes/find_deleted_notes.py index 548f94d..46a1718 100644 --- a/anknotes/find_deleted_notes.py +++ b/anknotes/find_deleted_notes.py @@ -16,7 +16,7 @@ def do_find_deleted_notes(all_anki_notes=None): Error = sqlite.Error - ENNotes = file(ANKNOTES.TABLE_OF_CONTENTS_ENEX, 'r').read() + enTableOfContents = file(ANKNOTES.TABLE_OF_CONTENTS_ENEX, 'r').read() # find = file(os.path.join(PATH, "powergrep-find.txt") , 'r').read().splitlines() # replace = file(os.path.join(PATH, "powergrep-replace.txt") , 'r').read().replace('https://www.evernote.com/shard/s175/nl/19775535/' , '').splitlines() @@ -24,7 +24,7 @@ def do_find_deleted_notes(all_anki_notes=None): find_guids = {} log_banner(' FIND DELETED EVERNOTE NOTES: UNIMPORTED EVERNOTE NOTES ', ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES) log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKI NOTES ', ANKNOTES.LOG_FDN_ANKI_ORPHANS) - log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKNOTES DB ENTRIES ', ANKNOTES.LOG_FDN_ANKI_ORPHANS) + log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKNOTES DB ENTRIES ', ANKNOTES.LOG_FDN_ANKNOTES_ORPHANS) log_banner(' FIND DELETED EVERNOTE NOTES: ANKNOTES TITLE MISMATCHES ', ANKNOTES.LOG_FDN_ANKNOTES_TITLE_MISMATCHES) log_banner(' FIND DELETED EVERNOTE NOTES: ANKI TITLE MISMATCHES ', ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES) anki_mismatch = 0 @@ -45,10 +45,9 @@ def do_find_deleted_notes(all_anki_notes=None): anki_mismatch += 1 mismatch = 0 missing_evernote_notes = [] - for match in find_evernote_links(ENNotes): - guid = match.group('guid') - title = match.group('Title') - title = clean_title(title) + for enLink in find_evernote_links(enTableOfContents): + guid = enLink.Guid + title = clean_title(enLink.FullTitle) title_safe = str_safe(title) if guid in find_guids: find_title = find_guids[guid] @@ -115,7 +114,7 @@ def do_find_deleted_notes(all_anki_notes=None): results = [ [ log[1], - log[0] if log[1] == 0 else '<a href="%s">%s</a>' % (get_log_full_path(log[2], True), log[0]), + log[0] if log[1] == 0 else '<a href="%s">%s</a>' % (get_log_full_path(log[2], as_url_link=True), log[0]), log[3] if len(log) > 3 else '' ] for log in logs] diff --git a/anknotes/html.py b/anknotes/html.py index 870d499..9ef3c51 100644 --- a/anknotes/html.py +++ b/anknotes/html.py @@ -17,12 +17,14 @@ def get_data(self): def strip_tags(html): + if html is None: return None s = MLStripper() s.feed(html) return s.get_data() def strip_tags_and_new_lines(html): + if html is None: return None return strip_tags(html).replace('\r\n', ' ').replace('\r', ' ').replace('\n', ' ') @@ -206,9 +208,9 @@ def tableify_column(column): def tableify_lines(rows, columns=None, tr_index_offset=0, return_html=True): if columns is None: columns = [] elif not isinstance(columns, list): columns = [columns] - trs = ['<tr class="tr%d">%s\n</tr>\n' % (i_row, ''.join(['\n <td class="td%d">%s</td>' % (i_col+1, tableify_column(column)) for i_col, column in enumerate(row if isinstance(row, list) else row.split('|'))])) for i_row, row in enumerate(columns + rows)] + trs = ['<tr class="tr%d%s">%s\n</tr>\n' % (i_row, ' alt' if i_row % 2 is 0 else ' std', ''.join(['\n <td class="td%d%s">%s</td>' % (i_col+1, ' alt' if i_col % 2 is 0 else ' std', tableify_column(column)) for i_col, column in enumerate(row if isinstance(row, list) else row.split('|'))])) for i_row, row in enumerate(columns + rows)] if return_html: - return '<table>%s</table>' % ''.join(trs) + return "<table cellspacing='0' style='border: 1px solid black;border-collapse: collapse;'>\n%s</table>" % ''.join(trs) return trs class EvernoteAccountIDs: diff --git a/anknotes/logging.py b/anknotes/logging.py index d2b3724..efe3038 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -3,10 +3,13 @@ import difflib import pprint import re - +import inspect +import shutil +import time # Anknotes Shared Imports from anknotes.constants import * from anknotes.graphics import * +from anknotes.stopwatch import clockit # Anki Imports try: @@ -69,7 +72,7 @@ def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0 if richText: textFormat = 1 - message = message.replace('\n', '<BR>\n') + # message = message.replace('\n', '<BR>\n') message = '<style>\n%s</style>\n\n%s' % (styleSheet, message) global messageBox messageBox = QMessageBox() @@ -81,7 +84,7 @@ def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0 messageBox.setTextFormat(textFormat) # message = ' %s %s' % (styleSheet, message) - # log(message, replace_newline=False) + log_plain(message, 'showInfo', clear=True) messageBox.setWindowIcon(icoEvernoteWeb) messageBox.setWindowIconText("Anknotes") messageBox.setText(message) @@ -150,33 +153,133 @@ def obj2log_simple(content): def convert_filename_to_local_link(filename): return 'file:///' + filename.replace("\\", "//") -def log_blank(filename='', clear=False, extension='log'): - log(timestamp=False, filename=filename, clear=clear, extension=extension) - - -def log_plain(content=None, filename='', prefix='', clear=False, extension='log', - replace_newline=None, do_print=False): - log(timestamp=False, content=content, filename=filename, prefix=prefix, clear=clear, extension=extension, - replace_newline=replace_newline, do_print=do_print) - -def log_banner(title, filename, length=80, append_newline=True): - log("-" * length, filename, clear=True, timestamp=False) - log(pad_center(title, length),filename, timestamp=False) - log("-" * length, filename, timestamp=False) - if append_newline: log_blank(filename) +class Logger(object): + base_path = None + caller_info=None + default_filename=None + def wrap_filename(self, filename=None): + if filename is None: filename = self.default_filename + if self.base_path is not None: + filename = os.path.join(self.base_path, filename if filename else '') + return filename + + def blank(self, filename=None, *args, **kwargs): + filename = self.wrap_filename(filename) + log_blank(filename=filename, *args, **kwargs) + + # def banner(self, title, filename=None, length=80, append_newline=True, do_print=False): + def banner(self, title, filename=None, *args, **kwargs): + filename = self.wrap_filename(filename) + log_banner(title=title, filename=filename, *args, **kwargs) + + def go(self, content=None, filename=None, wrap_filename=True, *args, **kwargs): + if wrap_filename: filename = self.wrap_filename(filename) + log(content=content, filename=filename, *args, **kwargs) + + # content=None, filename='', prefix='', clear=False, extension='log', replace_newline=None, do_print=False): + def plain(self, content=None, filename=None, *args, **kwargs): + filename=self.wrap_filename(filename) + log_plain(content=content, filename=filename, *args, **kwargs) + + log = do = add = go + + def default(self, *args, **kwargs): + self.log(wrap_filename=False, *args, **kwargs) + + def __init__(self, base_path=None, default_filename=None, rm_path=False): + self.default_filename = default_filename + if base_path: + self.base_path = base_path + else: + self.caller_info = caller_name() + if self.caller_info: + self.base_path = self.caller_info.Base.replace('.', '\\') + if rm_path: + rm_log_path(self.base_path) + + + +def log_blank(filename=None, *args, **kwargs): + log(timestamp=False, content=None, filename=filename, *args, **kwargs) + + +def log_plain(*args, **kwargs): + log(timestamp=False, *args, **kwargs) + +def rm_log_path(filename='*', subfolders_only=False, retry_errors=0): + path = os.path.dirname(os.path.abspath(get_log_full_path(filename))) + if path is ANKNOTES.FOLDER_LOGS or path in ANKNOTES.FOLDER_LOGS: return + rm_log_path.errors = [] + def del_subfolder(arg=None,dirname=None,filenames=None, is_subfolder=True): + def rmtree_error(f, p, e): + rm_log_path.errors += [p] + if is_subfolder and dirname is path: return + shutil.rmtree(dirname, onerror=rmtree_error) + if not subfolders_only: del_subfolder(dirname=path, is_subfolder=False) + else: os.path.walk(path, del_subfolder, None) + if rm_log_path.errors: + if retry_errors > 5: + print "Unable to delete log path" + log("Unable to delete log path as requested", filename) + return + time.sleep(1) + rm_log_path(filename, subfolders_only, retry_errors + 1) + +def log_banner(title, filename, length=80, append_newline=True, *args, **kwargs): + log("-" * length, filename, clear=True, timestamp=False, *args, **kwargs) + log(pad_center(title, length),filename, timestamp=False, *args, **kwargs) + log("-" * length, filename, timestamp=False, *args, **kwargs) + if append_newline: log_blank(filename, *args, **kwargs) + +_log_filename_history = [] +def set_current_log(fn): + global _log_filename_history + _log_filename_history.append(fn) + +def end_current_log(fn=None): + global _log_filename_history + if fn: + _log_filename_history.remove(fn) + else: + _log_filename_history = _log_filename_history[:-1] def get_log_full_path(filename='', extension='log', as_url_link=False): + global _log_filename_history + log_base_name = ANKNOTES.LOG_BASE_NAME + filename_suffix = '' + if filename and filename[0] == '*': + filename_suffix = '\\' + filename[1:] + log_base_name = '' + filename = None + if filename is None: + if ANKNOTES.LOG_USE_CALLER_NAME: + caller = caller_name() + if caller: + filename = caller.Base.replace('.', '\\') + if filename is None: + filename = _log_filename_history[-1] if _log_filename_history else ANKNOTES.LOG_ACTIVE if not filename: - filename = ANKNOTES.LOG_BASE_NAME + '.' + extension + filename = ANKNOTES.LOG_BASE_NAME + if not filename: filename = ANKNOTES.LOG_DEFAULT_NAME else: if filename[0] is '+': filename = filename[1:] - filename = ANKNOTES.LOG_BASE_NAME + '-%s.%s' % (filename, extension) + filename = (log_base_name + '-' if log_base_name and log_base_name[-1] != '\\' else '') + filename + filename += filename_suffix + '.' + extension + filename = re.sub(r'[^\w\-_\.\\]', '_', filename) full_path = os.path.join(ANKNOTES.FOLDER_LOGS, filename) if as_url_link: return convert_filename_to_local_link(full_path) return full_path -def log(content=None, filename='', prefix='', clear=False, timestamp=True, extension='log', +def log_main2(**kwargs): + log(filename=ANKNOTES.LOG_DEFAULT_NAME, **kwargs) + +def log_test(**kwargs): + print '\n'.join(caller_names()) + #log(filename=None, **kwargs) + +# @clockit +def log(content=None, filename=None, prefix='', clear=False, timestamp=True, extension='log', replace_newline=None, do_print=False): if content is None: content = '' else: @@ -202,17 +305,19 @@ def log(content=None, filename='', prefix='', clear=False, timestamp=True, exten else: st = '' if not os.path.exists(os.path.dirname(full_path)): - os.mkdir(os.path.dirname(full_path)) + os.makedirs(os.path.dirname(full_path)) + + # print "printing to %s: %s" % (full_path, content[:1000]) with open(full_path, 'w+' if clear else 'a+') as fileLog: print>> fileLog, prefix + ' ' + st + content if do_print: print prefix + ' ' + st + content -def log_sql(value): - log(value, 'sql') +def log_sql(content, **kwargs): + log(content, 'sql', **kwargs) -def log_error(value, crossPost=True): - log(value, '+' if crossPost else '' + 'error') +def log_error(content, crossPost=True, **kwargs): + log(content, '+' if crossPost else '' + 'error', **kwargs) def print_dump(obj): @@ -258,7 +363,7 @@ def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, exte pass if not os.path.exists(os.path.dirname(full_path)): - os.mkdir(os.path.dirname(full_path)) + os.makedirs(os.path.dirname(full_path)) with open(full_path, 'w+' if clear else 'a+') as fileLog: try: print>> fileLog, (u'\n %s%s' % (st, content)) @@ -292,13 +397,13 @@ def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, exte print>> fileLog, (u'\n %s%s' % (st, "Error printing content: " + content[:10])) -def log_api(method, content=''): +def log_api(method, content='', **kwargs): if content: content = ': ' + content - log(" API_CALL [%3d]: %10s%s" % (get_api_call_count(), method, content), 'api') + log(" API_CALL [%3d]: %10s%s" % (get_api_call_count(), method, content), 'api', **kwargs) def get_api_call_count(): - api_log = file(os.path.join(ANKNOTES.FOLDER_LOGS, ANKNOTES.LOG_BASE_NAME + '-api.log'), 'r').read().splitlines() + api_log = file(get_log_full_path('api'), 'r').read().splitlines() count = 1 for i in range(len(api_log), 0, -1): call = api_log[i - 1] @@ -312,4 +417,80 @@ def get_api_call_count(): return count return count -log('completed %s' % __name__, 'import') \ No newline at end of file +def caller_names(return_string=True, simplify=True): + return [c.Base if return_string else c for c in [__caller_name__(i,simplify) for i in range(0,20)] if c and c.Base] + +class CallerInfo: + Class=[] + Module=[] + Outer=[] + Name="" + simplify=True + __keywords_exclude__=['pydevd', 'logging', 'stopwatch'] + __keywords_strip__=['__maxin__', 'anknotes', '<module>'] + __outer__ = [] + filtered=True + @property + def __trace__(self): + return self.Module + self.Outer + self.Class + [self.Name] + + @property + def Trace(self): + t= self._strip_(self.__trace__) + return t if not self.filtered or not [e for e in self.__keywords_exclude__ if e in t] else [] + + @property + def Base(self): + return '.'.join(self._strip_(self.Module + self.Class + [self.Name])) if self.Trace else '' + + @property + def Full(self): + return '.'.join(self.Trace) + + def _strip_(self, lst): + return [t for t in lst if t and t not in self.__keywords_strip__] + + def __init__(self, parentframe=None): + """ + + :rtype : CallerInfo + """ + if not parentframe: return + self.Class = parentframe.f_locals['self'].__class__.__name__.split('.') if 'self' in parentframe.f_locals else [] + module = inspect.getmodule(parentframe) + self.Module = module.__name__.split('.') if module else [] + self.Name = parentframe.f_code.co_name if parentframe.f_code.co_name is not '<module>' else '' + self.__outer__ = [[f[1], f[3]] for f in inspect.getouterframes(parentframe) if f] + self.__outer__.reverse() + self.Outer = [f[1] for f in self.__outer__ if f and f[1] and not [exclude for exclude in self.__keywords_exclude__ + [self.Name] if exclude in f[0] or exclude in f[1]]] + del parentframe + +@clockit +def caller_name(skip=None, simplify=True, return_string=False): + if skip is None: + for c in [__caller_name__(i,simplify) for i in range(0,20)]: + if c and c.Base: + return c.Base if return_string else c + return None + c = __caller_name__(skip, simplify=simplify) + return c.Base if return_string else c + +def __caller_name__(skip=0, simplify=True): + """Get a name of a caller in the format module.class.method + + `skip` specifies how many levels of stack to skip while getting caller + name. skip=1 means "who calls me", skip=2 "who calls my caller" etc. + + An empty string is returned if skipped levels exceed stack height + :rtype : CallerInfo + """ + stack = inspect.stack() + start = 0 + skip + if len(stack) < start + 1: + return None + parentframe = stack[start][0] + c_info = CallerInfo(parentframe) + del parentframe + return c_info + +# log('completed %s' % __name__, 'import') \ No newline at end of file diff --git a/anknotes/menu.py b/anknotes/menu.py index e936829..90d89dc 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -21,7 +21,7 @@ # from anki.storage import Collection DEBUG_RAISE_API_ERRORS = False -log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') +# log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') # noinspection PyTypeChecker @@ -45,14 +45,16 @@ def anknotes_setup_menu(): ["SEPARATOR", None], ["Step &1: Process Anki Notes Without See Also Field", lambda: see_also(1)], ["Step &2: Extract Links from TOC", lambda: see_also(2)], + ["SEPARATOR", None], ["Step &3: Create Auto TOC Evernote Notes", lambda: see_also(3)], ["Step &4: Validate and Upload Auto TOC Notes", lambda: see_also(4)], + ["Step &5: Rebuild TOC/Outline Link Database", lambda: see_also(6)], ["SEPARATOR", None], - ["Step &5: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(5)], - ["Step &6: Update See Also Footer In Evernote Notes", lambda: see_also(6)], - ["Step &7: Validate and Upload Modified Evernote Notes", lambda: see_also(7)], + ["Step &6: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(7)], + ["Step &7: Update See Also Footer In Evernote Notes", lambda: see_also(8)], + ["Step &8: Validate and Upload Modified Evernote Notes", lambda: see_also(9)], ["SEPARATOR", None], - ["Step &8: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(8)] + ["Step &9: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(10)] ] ], ["&Maintenance Tasks", @@ -170,7 +172,7 @@ def find_deleted_notes(automated=False): anki_dels = returnedData['AnkiOrphans'] anki_dels_count = len(anki_dels) missing_evernote_notes = returnedData['MissingEvernoteNotes'] - missing_evernote_notes_count = len(missing_evernote_notes) + missing_evernote_notes_count = len(missing_evernote_notes) showInfo(info, richText=True, minWidth=600) db_changed = False if anknotes_dels_count > 0: @@ -206,30 +208,34 @@ def validate_pending_notes(showAlerts=True, uploadAfterValidation=True, callback if showAlerts: showInfo("""Press Okay to save and close your Anki collection, open the command-line note validation tool, and then re-open your Anki collection.%s -Anki will be unresponsive until the validation tool completes. This will take at least 45 seconds. - -The tool's output will be shown. If it is truncated, you may view the full log in the anknotes addon folder at extra\\logs\\anknotes-MakeNoteQueue-*.log""" +Anki will be unresponsive until the validation tool completes. This will take at least 45 seconds. The tool's output will be displayed upon completion. """ % ( - ' Any validated notes will be automatically uploaded once your Anki collection is reopened.' if uploadAfterValidation else '')) + ' You will be given the option of uploading successfully validated notes once your Anki collection is reopened.' if uploadAfterValidation else '')) handle = Popen(['python', ANKNOTES.VALIDATION_SCRIPT], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) stdoutdata, stderrdata = handle.communicate() stdoutdata = re.sub(' +', ' ', stdoutdata) info = ("ERROR: {%s}<HR>" % stderrdata) if stderrdata else '' - proceed = True - if showAlerts: + allowUpload = True + if showAlerts: tds = [[str(count), '<a href="%s">VIEW %s VALIDATIONS LOG</a>' % (fn, key.upper())] for key, fn, count in [ - [key, get_log_full_path(key, as_url_link=True), int(re.search(r'CHECKING +(\d{1,3}) ' + key.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1))] - for key in ['Pending', 'Successful', 'Failed']] if count > 0] - info += tableify_lines(tds, '#|Result') - proceed = showInfo("Completed: %s<BR>%s" % ( - 'Press Okay to begin uploading successfully validated notes to the Evernote Servers' if uploadAfterValidation else '', - info), cancelButton=True) + [key, get_log_full_path(key, as_url_link=True), int(re.search(r'CHECKING +(\d{1,3}) +' + key.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1))] + for key in ['Pending', 'Successful', 'Failed']] if count > 0] + if not tds: + show_tooltip("No notes found in the validation queue.") + allowUpload = False + else: + info += tableify_lines(tds, '#|Result') + successful = int(re.search(r'CHECKING +(\d{1,3}) +' + 'Successful'.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1)) + allowUpload = (uploadAfterValidation and successful > 0) + allowUpload = allowUpload & showInfo("Completed: %s<BR>%s" % ( + 'Press Okay to begin uploading %d successfully validated note(s) to the Evernote Servers' % successful if (uploadAfterValidation and successful > 0) else '', + info), cancelButton=(successful > 0), richText=True) # mw.col.reopen() # mw.col.load() - if callback is None and (uploadAfterValidation and proceed): + if callback is None and allowUpload: callback = upload_validated_notes external_tool_callback_timer(callback) @@ -269,7 +275,7 @@ def see_also(steps=None, showAlerts=None, validationComplete=False): else: steps = [-4] if 5 in steps: - log(" > See Also: Step 5: Inserting TOC/Outline Links Into Anki Notes") + log(" > See Also: Step 5: Inserting TOC/Outline Links Into Anki Notes' See Also Field") controller.anki.insert_toc_into_see_also() if 6 in steps: log(" > See Also: Step 6: Update See Also Footer In Evernote Notes") diff --git a/anknotes/shared.py b/anknotes/shared.py index 7d37b2c..0b05bc9 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -23,7 +23,7 @@ EDAMNotFoundException except: pass -log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') +# log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') def get_friendly_interval_string(lastImport): if not lastImport: return "" td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) @@ -67,13 +67,19 @@ def find_evernote_guids(content): return [x.group('guid') for x in re.finditer(r'\b(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b', content)] def find_evernote_links_as_guids(content): - return [x.group('guid') for x in find_evernote_links(content)] + return [x.Guid for x in find_evernote_links(content)] def replace_evernote_web_links(content): return re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', r'evernote:///view/\2/\1/\3/\3/', content) def find_evernote_links(content): + """ + + :param content: + :return: + :rtype : list[EvernoteLink] + """ # .NET regex saved to regex.txt as 'Finding Evernote Links' content = replace_evernote_web_links(content) regex_str = r'<a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)"(?:[^>]+)?>(?P<Title>.+?)</a>' @@ -82,8 +88,7 @@ def find_evernote_links(content): match = re.search(regex_str, content) if match: ids.update(match.group('uid'), match.group('shard')) - return re.finditer(regex_str, content) - + return [EvernoteLink(m) for m in re.finditer(regex_str, content)] def get_dict_from_list(lst, keys_to_ignore=list()): dic = {} @@ -91,10 +96,8 @@ def get_dict_from_list(lst, keys_to_ignore=list()): if not key in keys_to_ignore: dic[key] = value return dic - _regex_see_also = None - def update_regex(): global _regex_see_also regex_str = file(os.path.join(ANKNOTES.FOLDER_ANCILLARY, 'regex-see_also.txt'), 'r').read() diff --git a/anknotes/stopwatch/__init__.py b/anknotes/stopwatch/__init__.py index 4d0c41e..e642e10 100644 --- a/anknotes/stopwatch/__init__.py +++ b/anknotes/stopwatch/__init__.py @@ -34,41 +34,181 @@ def mult(a, b): """ -__version__ = '0.3.1' -__author__ = 'John Paulett <http://blog.7oars.com>' - +__version__ = '0.5' +__author__ = 'Avinash Puchalapalli <http://www.github.com/holycrepe/>' +__info__ = 'Forked from stopwatch 0.3.1 by John Paulett <http://blog.7oars.com>' class Timer(object): - __times__ = [] + __times = [] __stopped = None + __start = None + __count = 0 + __max = 0 + __laps = 0 + __interval = 100 + __parent_timer = None + """:type : Timer""" + + @property + def laps(self): + return len(self.__times) + + @property + def max(self): + return self.__max + + @max.setter + def max(self, value): + self.__max = int(value) + + @property + def parent(self): + return self.__parent_timer + + @parent.setter + def parent(self, value): + """:type value : Timer""" + self.__parent_timer = value + + @property + def parentTotal(self): + if not self.__parent_timer: return -1 + return self.__parent_timer.total + + @property + def percentOfParent(self): + if not self.__parent_timer: return -1 + return float(self.total) / float(self.parentTotal) * 100 + + @property + def percentOfParentStr(self): + return str(int(round(self.percentOfParent))) + '%' + + @property + def percentComplete(self): + return float(self.__count) / self.__max * 100 + + @property + def percentCompleteStr(self): + return str(int(round(self.percentComplete))) + '%' + + @property + def rate(self): + return self.rateCustom() + + @property + def rateStr(self): + return self.rateStrCustom() + + def rateCustom(self, unit=None): + if unit is None: unit = self.__interval + return self.elapsed/self.__count * unit + + def rateStrCustom(self, unit=None): + if unit is None: unit = self.__interval + return self.__timetostr__(self.rateCustom(unit)) + + @property + def count(self): + return self.__count + + @property + def projectedTime(self): + return self.__max * self.rateCustom(1) + + @property + def projectedTimeStr(self): + return self.__timetostr__(self.projectedTime) - def __init__(self, begin=True): + @property + def remainingTime(self): + return self.projectedTime - self.elapsed + + @property + def remainingTimeStr(self): + return self.__timetostr__(self.remainingTime) + + @property + def progress(self): + return '%4s (%3s): @ %3s/%d. %4s of %4s remaining' % (self.__timetostr__(), self.percentCompleteStr, self.rateStr, self.__interval, self.remainingTimeStr, self.projectedTimeStr) + + @property + def active(self): + return self.__start and not self.__stopped + + @property + def completed(self): + return self.__start and self.__stopped + + @property + def lap_info(self): + strs = [] + if self.active: + strs.append('Active: %s' % self.__timetostr__()) + elif self.completed: + strs.append('Latest: %s' % self.__timetostr__()) + elif self.laps>0: + strs.append('Last: %s' % self.__timetostr__(self.__times) ) + if self.laps > 0 + 0 if self.active or self.completed else 1: + strs.append('%2d Laps: %s' % (self.laps, self.__timetostr__(self.history))) + strs.append('Average: %s' % self.__timetostr__(self.average)) + if self.__parent_timer: + strs.append("Parent: %s" % self.__timetostr__(self.parentTotal)) + strs.append(" (%3s) " % self.percentOfParentStr) + return ' | '.join(strs) + + @property + def isProgressCheck(self): + return self.count % max(self.__interval, 1) is 0 + + def step(self, val=1): + self.__count += val + return self.isProgressCheck + + def __init__(self, begin=True, max=0, interval=100): if begin: self.reset() + self.__max = max + self.__interval = interval def start(self): self.reset() def reset(self): - if self.__stopped: - self.__times__.append(self.elapsed) + self.__count = 0 + if not self.__stopped: self.stop() self.__stopped = None self.__start = self.__time() def stop(self): """Stops the clock permanently for the instance of the Timer. Returns the time at which the instance was stopped. - """ + """ + if not self.__start: return -1 self.__stopped = self.__last_time() + self.__times.append(self.elapsed) return self.elapsed + @property + def history(self): + return sum(self.__times) + + @property + def total(self): + return self.history + self.elapsed + + @property + def average(self): + return float(self.history) / self.laps + def elapsed(self): """The number of seconds since the current time that the Timer object was created. If stop() was called, it is the number of seconds from the instance creation until stop() was called. """ + if not self.__start: return -1 return self.__last_time() - self.__start - + elapsed = property(elapsed) def start_time(self): @@ -99,27 +239,40 @@ def __time(self): """ return time.time() + def __timetostr__(self, total_seconds=None, short = True, pad=True): + if total_seconds is None: total_seconds=self.elapsed + total_seconds = int(round(total_seconds)) + if total_seconds < 60: + return str(total_seconds) + [' sec', 's'][short] + m, s = divmod(total_seconds, 60) + if short: + if total_seconds < 120: return '%d:%02d' % (m, s) + return ['%2dm'%m,'%-4s' % '%dm' % m][pad] + return '%dm %02d' % (m, s) + [' sec', 's'][short] + def __str__(self): """Nicely format the elapsed time """ - total_seconds = int(round(self.elapsed)) - if total_seconds < 60: - return str(total_seconds) + ' sec' - m, s = divmod(total_seconds, 60) - return '%dm %dsec' % (m, s) - + return self.__timetostr__() + +all_clockit_timers = {} def clockit(func): """Function decorator that times the evaluation of *func* and prints the execution time. """ - def new(*args, **kw): - t = Timer() + # fn = func.__name__ + # print "Request to clock %s" % fn + # return func(*args, **kw) + global all_clockit_timers + fn = func.__name__ + if fn not in all_clockit_timers: + all_clockit_timers[fn] = Timer() + else: + all_clockit_timers[fn].reset() retval = func(*args, **kw) - t.stop() - # log('Function %s completed in %s' % (func.__name__, t), "clockit") - del t + all_clockit_timers[fn].stop() + # print ('Function %s completed in %s\n > %s' % (fn, all_clockit_timers[fn].__timetostr__(short=False), all_clockit_timers[fn].lap_info)) return retval - return new diff --git a/anknotes/structs.py b/anknotes/structs.py index 1798f15..5d3c736 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -1,13 +1,23 @@ +import re +import anknotes from anknotes.db import * from anknotes.enum import Enum from anknotes.logging import log, str_safe, pad_center +from anknotes.html import strip_tags +from anknotes.enums import * # from evernote.edam.notestore.ttypes import NoteMetadata, NotesMetadataList def upperFirst(name): return name[0].upper() + name[1:] -from anknotes.EvernoteNotePrototype import EvernoteNotePrototype +def getattrcallable(obj, attr): + val = getattr(obj, attr) + if callable(val): return val() + return val + +# from anknotes.EvernoteNotePrototype import EvernoteNotePrototype +# from anknotes.EvernoteNoteTitle import EvernoteNoteTitle class EvernoteStruct(object): success = False @@ -17,61 +27,117 @@ class EvernoteStruct(object): __sql_table__ = TABLES.EVERNOTE.TAGS __sql_where__ = "guid" __attr_order__ = [] + __title_is_note_title = False + + def __attr_from_key__(self, key): + return upperFirst(key) def keys(self): - if len(self.__attr_order__) == 0: - self.__attr_order__ = self.__sql_columns__ - self.__attr_order__.append(self.__sql_where__) - return self.__attr_order__ + return self._valid_attributes_() + # if len(self.__attr_order__) == 0: + # self.__attr_order__ = self.__sql_columns__ + self.__sql_where__ + # self.__attr_order__.append(self.__sql_where__) + # return self.__attr_order__ def items(self): - lst = [] - for key in self.__attr_order__: - lst.append(getattr(self, upperFirst(key))) - return lst + return [self.getAttribute(key) for key in self.__attr_order__] - def getFromDB(self): + def getFromDB(self, allColumns=True): query = "SELECT %s FROM %s WHERE %s = '%s'" % ( - ', '.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, - getattr(self, upperFirst(self.__sql_where__))) + '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) + ankDB().setrowfactory() result = ankDB().first(query) if result: self.success = True - i = 0 - for c in self.__sql_columns__: - setattr(self, upperFirst(c), result[i]) - i += 1 + self.setFromKeyedObject(result) else: self.success = False return self.success + @property + def Where(self): + return self.getAttribute(self.__sql_where__) + + @Where.setter + def Where(self, value): + self.setAttribute(self.__sql_where__, value) + + def getAttribute(self, key, default=None, raiseIfInvalidKey=False): + if not self.hasAttribute(key): + if raiseIfInvalidKey: raise KeyError + return default + return getattr(self, self.__attr_from_key__(key)) + + def hasAttribute(self, key): + return hasattr(self, self.__attr_from_key__(key)) + + def setAttribute(self, key, value): + if key == "fetch_" + self.__sql_where__: + self.setAttribute(self.__sql_where__, value) + self.getFromDB() + elif self._is_valid_attribute_(key): + setattr(self, self.__attr_from_key__(key), value) + + def setAttributeByObject(self, key, keyed_object): + self.setAttribute(key, keyed_object[key]) + + def setFromKeyedObject(self, keyed_object, keys=None): + """ + + :param keyed_object: + :type: sqlite.Row | dict[str, object] | re.MatchObject | _sre.SRE_Match + :return: + """ + lst = self._valid_attributes_() + # keys_detected=True + if keys or isinstance(keyed_object, dict): + pass + elif isinstance(keyed_object, type(re.search('', ''))): + keyed_object = keyed_object.groupdict() + elif hasattr(keyed_object, 'keys'): + keys = getattrcallable(keyed_object, 'keys') + else: + # keys_detected=False + return False + + if keys is None: keys = keyed_object + for key in keys: + if key == "fetch_" + self.__sql_where__: + self.Where = keyed_object[key] + self.getFromDB() + elif key in lst: self.setAttributeByObject(key, keyed_object) + return True + + def setFromListByDefaultOrder(self, args): + i = 0 + max = len(self.__attr_order__) + for value in args: + if i > max: + log("Unable to set attr #%d for %s to %s (Exceeds # of default attributes)" % (i, self.__class__.__name__, str_safe(value)), 'error') + return + self.setAttribute(self.__attr_order__[i], value) + i += 1 + # I have no idea what I was trying to do when I coded the commented out conditional statement... + # if key in self.__attr_order__: + + # else: + # log("Unable to set attr #%d for %s to %s" % (i, self.__class__.__name__, str_safe(value)), 'error') + + + def _valid_attributes_(self): + return set().union(self.__sql_columns__, [self.__sql_where__], self.__attr_order__) + + def _is_valid_attribute_(self, attribute): + return attribute.lower() in self._valid_attributes_() + def __init__(self, *args, **kwargs): if isinstance(self.__sql_columns__, str): self.__sql_columns__ = [self.__sql_columns__] if isinstance(self.__attr_order__, str) or isinstance(self.__attr_order__, unicode): self.__attr_order__ = self.__attr_order__.replace('|', ' ').split(' ') args = list(args) - if len(args) > 0: - val = args[0] - if isinstance(val, sqlite.Row): - del args[0] - for key in val.keys(): - value = val[key] - kwargs[key] = value - i = 0 - for value in args: - key = self.__attr_order__[i] - if key in self.__attr_order__: - setattr(self, upperFirst(key), value) - else: - log("Unable to set attr #%d for %s to %s" % (i, self.__class__.__name__, str_safe(value)), 'error') - i += 1 - lst = set().union(self.__sql_columns__, [self.__sql_where__], self.__attr_order__) - for key in kwargs: - if key == "fetch_" + self.__sql_where__: - setattr(self, upperFirst(self.__sql_where__), kwargs[key]) - self.getFromDB() - elif key in lst: setattr(self, upperFirst(key), kwargs[key]) - + if args and self.setFromKeyedObject(args[0]): del args[0] + self.setFromListByDefaultOrder(args) + self.setFromKeyedObject(kwargs) class EvernoteNotebook(EvernoteStruct): Stack = "" @@ -85,6 +151,47 @@ class EvernoteTag(EvernoteStruct): __sql_table__ = TABLES.EVERNOTE.TAGS + + +class EvernoteLink(EvernoteStruct): + __uid__ = -1 + Shard = -1 + Guid = "" + __title__ = None + """:type: EvernoteNoteTitle.EvernoteNoteTitle """ + __attr_order__ = 'uid|shard|guid|title' + + @property + def HTML(self): + return self.Title.HTML + + @property + def Title(self): + """:rtype : EvernoteNoteTitle.EvernoteNoteTitle""" + return self.__title__ + + @property + def FullTitle(self): + return self.Title.FullTitle + + @Title.setter + def Title(self, value): + """ + :param value: + :type value : EvernoteNoteTitle.EvernoteNoteTitle | str | unicode + :return: + """ + self.__title__ = anknotes.EvernoteNoteTitle.EvernoteNoteTitle(value) + """:type : EvernoteNoteTitle.EvernoteNoteTitle""" + + @property + def Uid(self): + return int(self.__uid__) + + @Uid.setter + def Uid(self, value): + self.__uid__ = int(value) + class EvernoteTOCEntry(EvernoteStruct): RealTitle = "" """:type : str""" @@ -121,31 +228,92 @@ def __init__(self, *args, **kwargs): super(self.__class__, self).__init__(*args, **kwargs) -class EvernoteAPIStatus(Enum): - Uninitialized, EmptyRequest, RequestQueued, Success, RateLimitError, SocketError, UserError, NotFoundError, UnhandledError, Unknown = range( - -3, 7) - # Uninitialized = -100 - # NoParameters = -1 - # Success = 0 - # RateLimitError = 1 - # SocketError = 2 - # UserError = 3 - # NotFoundError = 4 - # UnhandledError = 5 - # Unknown = 100 +class EvernoteAPIStatusOld(AutoNumber): + Uninitialized = -100 + """:type : EvernoteAPIStatus""" + EmptyRequest = -3 + """:type : EvernoteAPIStatus""" + Manual = -2 + """:type : EvernoteAPIStatus""" + RequestQueued = -1 + """:type : EvernoteAPIStatus""" + Success = 0 + """:type : EvernoteAPIStatus""" + RateLimitError = () + """:type : EvernoteAPIStatus""" + SocketError = () + """:type : EvernoteAPIStatus""" + UserError = () + """:type : EvernoteAPIStatus""" + NotFoundError = () + """:type : EvernoteAPIStatus""" + UnhandledError = () + """:type : EvernoteAPIStatus""" + Unknown = 100 + """:type : EvernoteAPIStatus""" + + def __getitem__(self, item): + """:rtype : EvernoteAPIStatus""" + + return super(self.__class__, self).__getitem__(item) + + # def __new__(cls, *args, **kwargs): + # """:rtype : EvernoteAPIStatus""" + # return type(cls).__new__(*args, **kwargs) + + @property + def IsError(self): + return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value + + @property + def IsSuccessful(self): + return EvernoteAPIStatus.Success.value >= self.value > EvernoteAPIStatus.Uninitialized.value + + @property + def IsSuccess(self): + return self == EvernoteAPIStatus.Success + + + +class EvernoteAPIStatus(AutoNumberedEnum): + Uninitialized = -100 + """:type : EvernoteAPIStatus""" + EmptyRequest = -3 + """:type : EvernoteAPIStatus""" + Manual = -2 + """:type : EvernoteAPIStatus""" + RequestQueued = -1 + """:type : EvernoteAPIStatus""" + Success = 0 + """:type : EvernoteAPIStatus""" + RateLimitError = () + """:type : EvernoteAPIStatus""" + SocketError = () + """:type : EvernoteAPIStatus""" + UserError = () + """:type : EvernoteAPIStatus""" + NotFoundError = () + """:type : EvernoteAPIStatus""" + UnhandledError = () + """:type : EvernoteAPIStatus""" + Unknown = 100 + """:type : EvernoteAPIStatus""" + + # def __new__(cls, *args, **kwargs): + # """:rtype : EvernoteAPIStatus""" + # return type(cls).__new__(*args, **kwargs) @property def IsError(self): - return (self != EvernoteAPIStatus.Unknown and self.value > EvernoteAPIStatus.Success.value) + return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value @property def IsSuccessful(self): - return ( - self == EvernoteAPIStatus.Success or self == EvernoteAPIStatus.EmptyRequest or self == EvernoteAPIStatus.RequestQueued) + return EvernoteAPIStatus.Success.value >= self.value > EvernoteAPIStatus.Uninitialized.value @property def IsSuccess(self): - return (self == EvernoteAPIStatus.Success) + return self == EvernoteAPIStatus.Success class EvernoteImportType: @@ -156,7 +324,7 @@ class EvernoteNoteFetcherResult(object): def __init__(self, note=None, status=None, source=-1): """ - :type note: EvernoteNotePrototype + :type note: EvernoteNotePrototype.EvernoteNotePrototype :type status: EvernoteAPIStatus """ if not status: status = EvernoteAPIStatus.Uninitialized @@ -271,7 +439,7 @@ def __init__(self, status=None, local=None): self.Imported = 0 self.Notes = [] """ - :type : list[EvernoteNotePrototype] + :type : list[EvernoteNotePrototype.EvernoteNotePrototype] """ def reportResult(self, result): diff --git a/anknotes_standAlone.py b/anknotes_standAlone.py index 5f7b941..de0fd87 100644 --- a/anknotes_standAlone.py +++ b/anknotes_standAlone.py @@ -50,7 +50,7 @@ currentLog = 'Successful' log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d SUCCESSFUL MAKE NOTE QUEUE ITEMS " % len(success_queued_items), 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + log(" CHECKING %3d SUCCESSFUL MAKE NOTE QUEUE ITEMS " % len(success_queued_items), 'MakeNoteQueue\\' + currentLog, timestamp=False, do_print=True) log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) for result in success_queued_items: @@ -61,7 +61,7 @@ currentLog = 'Failed' log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d FAILED MAKE NOTE QUEUE ITEMS " % len(failed_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) + log(" CHECKING %3d FAILED MAKE NOTE QUEUE ITEMS " % len(failed_queued_items), 'MakeNoteQueue\\' + currentLog, clear=False, timestamp=False, do_print=True) log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) @@ -78,7 +78,7 @@ currentLog = 'Pending' log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d PENDING MAKE NOTE QUEUE ITEMS " % len(pending_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) + log(" CHECKING %3d PENDING MAKE NOTE QUEUE ITEMS " % len(pending_queued_items), 'MakeNoteQueue\\' + currentLog, clear=False, timestamp=False, do_print=True) log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) timerFull = stopwatch.Timer() diff --git a/anknotes_start_bare.py b/anknotes_start_bare.py new file mode 100644 index 0000000..e386237 --- /dev/null +++ b/anknotes_start_bare.py @@ -0,0 +1,9 @@ +try: + from aqt.utils import getText + isAnki = True +except: + isAnki = False + +if not isAnki: + from anknotes import bare + bare.main_bare() \ No newline at end of file From aa6933d0b03cc24ade44ba271f46681e5d2bd8b2 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Tue, 22 Sep 2015 12:08:21 -0400 Subject: [PATCH 07/70] Confirmed no references to generateTocTitle, all are to EvernoteNoteTitle.generateTOCTitle --- anknotes/Anki.py | 44 +++++++++---------- anknotes/EvernoteNoteFetcher.py | 17 +++++++- anknotes/EvernoteNoteTitle.py | 3 +- anknotes/ankEvernote.py | 49 +++++++++++++-------- anknotes/bare.py | 59 ++++++++++++------------- anknotes/find_deleted_notes.py | 13 ++++-- anknotes/logging.py | 65 +++++++++++----------------- anknotes/shared.py | 32 ++++++++------ anknotes/stopwatch/__init__.py | 10 ++--- anknotes/structs.py | 22 +++++++--- anknotes_start_find_deleted_notes.py | 2 + 11 files changed, 178 insertions(+), 138 deletions(-) diff --git a/anknotes/Anki.py b/anknotes/Anki.py index b68f2a8..fac76a9 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -363,10 +363,11 @@ def insert_toc_into_see_also(self): count = 0 count_update = 0 max_count = len(grouped_results) + log = Logger() for source_guid, toc_guids in grouped_results.items(): ankiNote = self.get_anki_note_from_evernote_guid(source_guid) if not ankiNote: - log_dump(toc_guids, 'Missing Anki Note for ' + source_guid, 'insert_toc', timestamp=False) + log.dump(toc_guids, 'Missing Anki Note for ' + source_guid, 'insert_toc', timestamp=False) else: fields = get_dict_from_list(ankiNote.items()) see_also_html = fields[FIELDS.SEE_ALSO] @@ -389,36 +390,37 @@ def insert_toc_into_see_also(self): see_also_new += (toc_delimiter + toc_link) if flat_links else (u'\n<li>%s</li>' % toc_link) toc_delimiter = toc_separator if flat_links: - find_div_end = see_also_html.rfind('</div>') - 1 - if find_div_end > 0: + find_div_end = see_also_html.rfind('</div>') + if find_div_end > -1: + log.blank() + log.plain('Inserting Flat Links at position %d:' % find_div_end) + log.plain(see_also_html[:find_div_end]) + log.plain(see_also_html[find_div_end:]) see_also_html = see_also_html[:find_div_end] + see_also_new + '\n' + see_also_html[ find_div_end:] see_also_new = '' else: - see_also_toc_header = u'<br><div style="margin-top:5px;">\n%s</div><ol style="margin-top:3px;">' % generate_evernote_span( - '<u>TABLE OF CONTENTS</u>:', 'Levels', 'Auto TOC', escape=False) - see_also_toc_header_ul = see_also_toc_header.replace('<ol ', '<ul ') + see_also_toc_headers = {'ol': u'<br><div style="margin-top:5px;">\n%s</div><ol style="margin-top:3px;">' % generate_evernote_span( + '<u>TABLE OF CONTENTS</u>:', 'Levels', 'Auto TOC', escape=False)} + see_also_toc_headers['ul'] = see_also_toc_headers['ol'].replace('<ol ', '<ul ') - if see_also_toc_header_ul in see_also_html: - find_ul_end = see_also_html.rfind('</ul>') - 1 + if see_also_toc_headers['ul'] in see_also_html: + find_ul_end = see_also_html.rfind('</ul>') see_also_html = see_also_html[:find_ul_end] + '</ol>' + see_also_html[find_ul_end + 5:] - see_also_html = see_also_html.replace(see_also_toc_header_ul, see_also_toc_header) - if see_also_toc_header in see_also_html: - find_ol_end = see_also_html.rfind('</ol>') - 1 - see_also_html = see_also_html[:find_ol_end] + see_also_new + '\n' + see_also_html[ - find_ol_end:] + see_also_html = see_also_html.replace(see_also_toc_headers['ul'], see_also_toc_headers['ol']) + if see_also_toc_headers['ol'] in see_also_html: + find_ol_end = see_also_html.rfind('</ol>') + see_also_html = see_also_html[:find_ol_end] + see_also_new + '\n' + see_also_html[find_ol_end:] see_also_new = '' else: - if new_toc_count is 1: - see_also_new = see_also_toc_header_ul + u'%s\n</ul>' % see_also_new - else: - see_also_new = see_also_toc_header + u'%s\n</ol>' % see_also_new + header_type = 'ul' if new_toc_count is 1 else 'ul' + see_also_new = see_also_toc_headers[header_type] + u'%s\n</%s>' % (see_also_new, header_type) if see_also_count == 0: see_also_html = generate_evernote_span(u'See Also:', 'Links', 'See Also') see_also_html += see_also_new see_also_html = see_also_html.replace('<ol>', '<ol style="margin-top:3px;">') - log('<h3>%s</h3><br>' % generate_evernote_span(fields[FIELDS.TITLE], 'Links', - 'TOC') + see_also_html + u'<HR>', 'see_also', + log.add('<h3>%s</h3><br>' % generate_evernote_span(fields[FIELDS.TITLE], 'Links', + 'TOC') + see_also_html + u'<HR>', '_see_also', timestamp=False, extension='htm') fields[FIELDS.SEE_ALSO] = see_also_html.replace('evernote:///', 'evernote://') anki_note_prototype = AnkiNotePrototype(self, fields, ankiNote.tags, ankiNote, count=count, @@ -434,11 +436,9 @@ def extract_links_from_toc(self): toc_evernote_guids = self.get_evernote_guids_and_anki_fields_from_anki_note_ids(toc_anki_ids) query_update_toc_links = "UPDATE %s SET is_toc = 1 WHERE " % TABLES.SEE_ALSO delimiter = "" - # link_exists = 0 for toc_evernote_guid, fields in toc_evernote_guids.items(): for enLink in find_evernote_links(fields[FIELDS.CONTENT]): target_evernote_guid = enLink.Guid - # link_title = strip_tags(match.group('Title')) link_number = 1 + ankDB().scalar("select COUNT(*) from %s WHERE source_evernote_guid = '%s' " % ( TABLES.SEE_ALSO, target_evernote_guid)) toc_link_title = fields[FIELDS.TITLE] @@ -503,7 +503,7 @@ def insert_toc_and_outline_contents_into_notes(self): else: toc_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % ( toc_count, linked_note_title) - note_toc += "<BR><HR>" + note_toc += "<br><hr>" note_toc += linked_note_contents log(" > Appending TOC #%d contents" % toc_count, 'See Also') diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index cedc23b..ce5d71e 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -20,6 +20,9 @@ def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): self.result = EvernoteNoteFetcherResult() self.api_calls = 0 self.keepEvernoteTags = True + self.deleteQueryTags = True + self.evernoteQueryTags = [] + self.tagsToDelete = [] self.tagNames = [] self.tagGuids = [] self.use_local_db_only = use_local_db_only @@ -66,9 +69,18 @@ def getNoteLocal(self): log(" > getNoteLocal: GUID: '%s': %-40s" % (self.evernote_guid, db_note['title']), 'api') assert db_note['guid'] == self.evernote_guid self.reportSuccess(EvernoteNotePrototype(db_note=db_note), 1) - self.tagNames = self.result.Note.TagNames if self.keepEvernoteTags else [] + self.setNoteTags(tag_names=self.result.Note.TagNames) return True + def setNoteTags(self, tag_names=None, tag_guids=None): + if not self.keepEvernoteTags: + self.tagNames = [] + self.tagGuids = [] + return + if not tag_names: tag_names = self.tagNames + if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else self.whole_note.tagGuids + self.tagNames, self.tagGuids = self.evernote.get_matching_tag_data(tag_guids, tag_names) + def addNoteFromServerToDB(self, whole_note=None, tag_names=None): """ Adds note to Anknote DB from an Evernote Note object provided by the Evernote API @@ -135,7 +147,8 @@ def getNoteRemote(self): # return None if not self.getNoteRemoteAPICall(): return False self.api_calls += 1 - self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_evernote_guids(self.whole_note.tagGuids) + # self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_evernote_guids(self.whole_note.tagGuids) + self.setNoteTags(tag_guids=self.whole_note.tagGuids) self.addNoteFromServerToDB() if not self.keepEvernoteTags: self.tagNames = [] self.reportSuccess(EvernoteNotePrototype(whole_note=self.whole_note, tags=self.tagNames)) diff --git a/anknotes/EvernoteNoteTitle.py b/anknotes/EvernoteNoteTitle.py index 8af34cd..0ed73ac 100644 --- a/anknotes/EvernoteNoteTitle.py +++ b/anknotes/EvernoteNoteTitle.py @@ -1,10 +1,11 @@ +# -*- coding: utf-8 -*- ### Anknotes Shared Imports from anknotes.shared import * from sys import stderr # log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') def generateTOCTitle(title): title = EvernoteNoteTitle.titleObjectToString(title).upper() - for chr in u'?????': + for chr in u'αβδφḃ': title = title.replace(chr.upper(), chr) return title diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 7ea6c93..2f4d7bf 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -64,7 +64,6 @@ def __init__(self): self.hasValidator = eTreeImported if ankDBIsLocal(): return - self.keepEvernoteTags = mw.col.conf.get(SETTINGS.KEEP_EVERNOTE_TAGS, SETTINGS.KEEP_EVERNOTE_TAGS_DEFAULT_VALUE) auth_token = mw.col.conf.get(SETTINGS.EVERNOTE_AUTH_TOKEN, False) if not auth_token: # First run of the Plugin we did not save the access key yet @@ -309,6 +308,7 @@ def makeNote(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None return EvernoteAPIStatus.Success, note def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): + global inAnki """ Create EvernoteNote objects from Evernote GUIDs using EvernoteNoteFetcher.getNote(). Will prematurely return if fetcher.getNote fails @@ -325,7 +325,11 @@ def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): if len(evernote_guids) == 0: fetcher.results.Status = EvernoteAPIStatus.EmptyRequest return fetcher.results - fetcher.keepEvernoteTags = self.keepEvernoteTags + if inAnki: + fetcher.evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE).split() + fetcher.keepEvernoteTags = mw.col.conf.get(SETTINGS.KEEP_EVERNOTE_TAGS, SETTINGS.KEEP_EVERNOTE_TAGS_DEFAULT_VALUE) + fetcher.deleteQueryTags = mw.col.conf.get(SETTINGS.DELETE_EVERNOTE_TAGS_TO_IMPORT, True) + fetcher.tagsToDelete = mw.col.conf.get(SETTINGS.EVERNOTE_TAGS_TO_DELETE, "").split() for evernote_guid in self.evernote_guids: self.evernote_guid = evernote_guid if not fetcher.getNote(evernote_guid): @@ -424,6 +428,7 @@ def update_tags_db(self): log_api("listTags") try: tags = self.noteStore.listTags(self.token) + """: type : list[evernote.edam.type.ttypes.Tag] """ except EDAMSystemException as e: if HandleEDAMRateLimitError(e, api_action_str): if DEBUG_RAISE_API_ERRORS: raise @@ -435,30 +440,40 @@ def update_tags_db(self): return None raise data = [] - if not hasattr(self, 'tag_data'): self.tag_data = {} + if not hasattr(self, 'tag_data'): self.tag_data = {} for tag in tags: - self.tag_data[tag.guid] = tag.name - data.append([tag.guid, tag.name, tag.parentGuid, tag.updateSequenceNum]) + enTag = EvernoteTag(tag) + self.tag_data[tag.guid] = enTag + data.append(enTag.items()) ankDB().execute("DROP TABLE %s " % TABLES.EVERNOTE.TAGS) ankDB().InitTags(True) - ankDB().executemany( - "INSERT OR REPLACE INTO `%s`(`guid`,`name`,`parentGuid`,`updateSequenceNum`) VALUES (?, ?, ?, ?)" % TABLES.EVERNOTE.TAGS, - data) + ankDB().executemany(enTag.sqlUpdateQuery(), data) - def get_tag_names_from_evernote_guids(self, tag_guids_original): + def set_tag_data(self): + if not hasattr(self, 'tag_data'): + self.tag_data = {x.guid: EvernoteTag(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS)} + + def get_missing_tags(self, current_tags, from_guids=True): + if instance(current_tags, list): current_tags = set(current_tags) + return current_tags - set(self.tag_data.keys() if from_guids else [v.Name for k, v in self.tag_data.items()]) + + def get_matching_tag_data(self, tag_guids=None, tag_names=None): tagGuids = [] tagNames = [] - if not hasattr(self, 'tag_data'): - self.tag_data = {x.guid: x.name for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS)} - missing_tags = [x for x in tag_guids_original if x not in self.tag_data] - if len(missing_tags) > 0: + self.set_tag_data() + current_tags = set(tags_original) + from_guids = True if tag_guids else False + tags_original = tag_guids or tag_names + if self.get_missing_tags(tags_original, from_guids): self.update_tags_db() - missing_tags = [x for x in tag_guids_original if x not in self.tag_data] - if len(missing_tags) > 0: - log_error("FATAL ERROR: Tag Guid(s) %s were not found on the Evernote Servers" % str(missing_tags)) + missing_tags = self.get_missing_tags(tags_original, from_guids) + if missing_tags: + log_error("FATAL ERROR: Tag %s(s) %s were not found on the Evernote Servers" % ('Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)))) raise EDAMNotFoundException() - tagNamesToImport = get_tag_names_to_import({x: self.tag_data[x] for x in tag_guids_original}) + if from_guids: tags_dict = {x: self.tag_data[x] for x in tags_original} + else: tags_dict = {[k for k, v in tag_data.items() if v.Name is tag_name][0]: tag_name for tag_name in tags_original} + tagNamesToImport = get_tag_names_to_import(tags_dict) """:type : dict[string, EvernoteTag]""" if tagNamesToImport: is_struct = None diff --git a/anknotes/bare.py b/anknotes/bare.py index cc7c7dc..dfe60cf 100644 --- a/anknotes/bare.py +++ b/anknotes/bare.py @@ -144,15 +144,13 @@ def __init__(self, content=None): self.__content__ = content self.__match_attempted__ = 0 self.__matchobject__ = None - """:type : __Match """ - - + """:type : __Match """ content = pstrings() see_also = pstrings() - old = version() new = version() rgx = regex_see_also() + match_type = 'NA' def str_process(strr): @@ -169,15 +167,15 @@ def main_bare(): @clockit def print_results(): diff = generate_diff(n.old.see_also.updated, n.new.see_also.updated) - log.plain(diff, 'SeeAlsoDiff\\Diff\\' + enNote.FullTitle, extension='htm', clear=True) - log.plain(n.old.see_also.updated, 'SeeAlsoDiff\\Original\\' + enNote.FullTitle, extension='htm', clear=True) - log.plain(n.new.see_also.updated, 'SeeAlsoDiff\\New\\' + enNote.FullTitle, extension='htm', clear=True) - log.plain(diff + '\r\n') + log.plain(diff, 'SeeAlsoDiff\\Diff\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) + log.plain(diffify(n.old.see_also.updated,split=False), 'SeeAlsoDiff\\Original\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) + log.plain(diffify(n.new.see_also.updated,split=False), 'SeeAlsoDiff\\New\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) + log.plain(diff + '\n', 'SeeAlsoDiff\\__All') # diff = generate_diff(see_also_replace_old, see_also_replace_new) # log_plain(diff, 'SeeAlsoDiff\\Diff\\' + enNote.FullTitle, clear=True) # log_plain(see_also_replace_old, 'SeeAlsoDiff\\Original\\' + enNote.FullTitle, clear=True) # log_plain(see_also_replace_new, 'SeeAlsoDiff\\New\\' + enNote.FullTitle, clear=True) - # log_plain(diff + '\r\n' , logall) + # log_plain(diff + '\n' , logall) @clockit def process_note(): @@ -187,18 +185,22 @@ def process_note(): # see_also_match_old = n.old.content.regex_original.__matchobject__ # if not see_also_match_old: if not n.old.content.regex_original.successful_match: - log.go("Could not get see also match for %s" % target_evernote_guid) - log.plain(enNote.Guid + '\r\n' + ', '.join(enNote.TagNames) + '\r\n' + n.old.content.original, 'SeeAlsoMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) + # log.go("Could not get see also match for %s" % target_evernote_guid) + # new_content = old_content.replace('</en-note>', '<div><span><br/></span></div>' + n.new.see_also + '\n</en-note>') n.new.content = notes.version.pstrings(n.old.content.original.replace('</en-note>', '<div><span><br/></span></div>' + n.new.see_also.original + '\n</en-note>')) # see_also_replace_new = new_content # see_also_replace_old = old_content - n.new.see_also.updated = n.new.content.original - n.old.see_also.updated = n.old.content.original + # ????????????n.new.see_also.updated = str_process(n.new.content.original) + n.new.see_also.updated = str_process(n.new.content.original) + n.old.see_also.updated = str_process(n.old.content.original) + log.plain((target_evernote_guid + '<BR>' if target_evernote_guid != enNote.Guid else '') + enNote.Guid + '<BR>' + ', '.join(enNote.TagNames) + '<HR>' + enNote.Content + '<HR>' + n.new.see_also.updated, 'SeeAlsoMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) + n.match_type = 'V1' else: # see_also_old = see_also_match_old.group(0) # see_also_old = n.old.content.regex_original.main n.old.see_also = notes.version.pstrings(n.old.content.regex_original.main) + n.match_type = 'V2' # see_also_old_processed = str_process(see_also_old) # see_also_old_processed = n.old.see_also.processed @@ -213,6 +215,8 @@ def process_note(): # old_content = old_content.replace(see_also_old, see_also_old_processed) # see_also_match_old = see_also_match_old_processed n.old.see_also.useProcessed() + # log.go("Able to use processed old see also content") + n.match_type += 'V3' # see_also_match_old = n.old.see_also.updated # xxx = n.old.content.original.match.processed @@ -226,15 +230,16 @@ def process_note(): # see_also_match_new = rgx.search(see_also_new + '</en-note>') # if not see_also_match_new: if not n.new.see_also.regex_original.successful_match: - log.go("Could not get see also new match for %s" % target_evernote_guid) - # log_plain(enNote.Guid + '\r\n' + ', '.join(enNote.TagNames) + '\r\n' + see_also_new, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, clear=True) - log.plain(enNote.Guid + '\r\n' + ', '.join(enNote.TagNames) + '\r\n' + n.new.see_also.original.content, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) + # log.go("Could not get see also new match for %s" % target_evernote_guid) + # log_plain(enNote.Guid + '\n' + ', '.join(enNote.TagNames) + '\n' + see_also_new, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, clear=True) + log.plain(enNote.Guid + '\n' + ', '.join(enNote.TagNames) + '\n' + n.new.see_also.original.content, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) # see_also_replace_old = see_also_old_group_only_processed see_also_replace_old = n.old.content.original.match.processed.see_also.processed.content n.old.see_also.updated = n.old.content.regex_updated.see_also # see_also_replace_new = see_also_new_processed # see_also_replace_new = n.new.see_also.processed.content n.new.see_also.updated = n.new.see_also.processed + n.match_type + 'V4' else: # see_also_replace_old = see_also_match_old.group('SeeAlsoContent') # see_also_replace_old = n.old.content.original.match.processed.see_also_content @@ -242,44 +247,40 @@ def process_note(): n.old.see_also.updated = notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content # see_also_replace_new = see_also_match_new.group('SeeAlsoContent') # see_also_replace_new = n.new.see_also.original.see_also_content - n.new.see_also.updated = n.new.see_also.regex_original.see_also_content + # n.new.see_also.updated = n.new.see_also.regex_original.see_also_content + n.new.see_also.updated = str_process(n.new.see_also.regex_original.see_also_content) + n.match_type += 'V5' n.new.content.updated = n.old.content.updated.replace(n.old.see_also.updated, n.new.see_also.updated) # new_content = old_content.replace(see_also_replace_old, see_also_replace_new) # n.new.content = notes.version.pmatches() - log = Logger(default_filename='SeeAlsoDiff\\__ALL', rm_path=True) results = [x[0] for x in ankDB().all( "SELECT DISTINCT target_evernote_guid FROM %s WHERE 1 ORDER BY title ASC " % TABLES.SEE_ALSO)] changed = 0 # rm_log_path(subfolders_only=True) - log.banner(" UPDATING EVERNOTE SEE ALSO CONTENT ", do_print=True) - + log.banner("UPDATING EVERNOTE SEE ALSO CONTENT", do_print=True) tmr = stopwatch.Timer(max=len(results), interval=25) tmr.max = len(results) for target_evernote_guid in results: enNote = NotesDB.getEnNoteFromDBByGuid(target_evernote_guid) n = notes() if tmr.step(): - print "Note #%4s: %4s: %s" % (str(tmr.count), tmr.progress, - enNote.FullTitle if enNote.Status.IsSuccess else '(%s)' % target_evernote_guid) + print "Note %5s: %s: %s" % ('#' + str(tmr.count), tmr.progress, enNote.FullTitle if enNote.Status.IsSuccess else '(%s)' % target_evernote_guid) if not enNote.Status.IsSuccess: log.go("Could not get en note for %s" % target_evernote_guid) continue for tag in [EVERNOTE.TAG.TOC, EVERNOTE.TAG.OUTLINE]: if tag in enNote.TagNames: break else: - flds = ankDB().scalar("SELECT flds FROM notes WHERE flds LIKE '%%%s%s%%'" % ( - FIELDS.EVERNOTE_GUID_PREFIX, target_evernote_guid)) + flds = ankDB().scalar("SELECT flds FROM notes WHERE flds LIKE '%%%s%s%%'" % (FIELDS.EVERNOTE_GUID_PREFIX, target_evernote_guid)) n.new.see_also = notes.version.pstrings(flds.split("\x1f")[FIELDS.SEE_ALSO_FIELDS_ORD]) process_note() - if n.old.see_also.updated == n.new.see_also.updated: continue + if n.match_type != 'V1' and str_process(n.old.see_also.updated) == n.new.see_also.updated: continue # if see_also_replace_old == see_also_replace_new: continue print_results() changed += 1 enNote.Content = n.new.content.updated - print "Total %d changed out of %d " % (changed, tmr.max) - - - + +## HOCM/MVP \ No newline at end of file diff --git a/anknotes/find_deleted_notes.py b/anknotes/find_deleted_notes.py index 46a1718..e4fe991 100644 --- a/anknotes/find_deleted_notes.py +++ b/anknotes/find_deleted_notes.py @@ -19,18 +19,24 @@ def do_find_deleted_notes(all_anki_notes=None): enTableOfContents = file(ANKNOTES.TABLE_OF_CONTENTS_ENEX, 'r').read() # find = file(os.path.join(PATH, "powergrep-find.txt") , 'r').read().splitlines() # replace = file(os.path.join(PATH, "powergrep-replace.txt") , 'r').read().replace('https://www.evernote.com/shard/s175/nl/19775535/' , '').splitlines() - - all_anknotes_notes = ankDB().all("SELECT guid, title FROM %s " % TABLES.EVERNOTE.NOTES) + + all_anknotes_notes = ankDB().all("SELECT guid, title, tagNames FROM %s " % TABLES.EVERNOTE.NOTES) find_guids = {} log_banner(' FIND DELETED EVERNOTE NOTES: UNIMPORTED EVERNOTE NOTES ', ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES) log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKI NOTES ', ANKNOTES.LOG_FDN_ANKI_ORPHANS) log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKNOTES DB ENTRIES ', ANKNOTES.LOG_FDN_ANKNOTES_ORPHANS) log_banner(' FIND DELETED EVERNOTE NOTES: ANKNOTES TITLE MISMATCHES ', ANKNOTES.LOG_FDN_ANKNOTES_TITLE_MISMATCHES) log_banner(' FIND DELETED EVERNOTE NOTES: ANKI TITLE MISMATCHES ', ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES) + log_banner(' FIND DELETED EVERNOTE NOTES: POSSIBLE TOC NOTES MISSING TAG ', ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES + '_possibletoc') anki_mismatch = 0 + is_toc_or_outline=[] for line in all_anknotes_notes: guid = line['guid'] title = line['title'] + if not (',' + EVERNOTE.TAG.TOC + ',' in line['tagNames']): + if title.upper() == title: + log_plain(guid + '::: %-50s: ' % line['tagNames'][1:-1] + title, ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES + '_possibletoc', do_print=True) + title = clean_title(title) title_safe = str_safe(title) find_guids[guid] = title @@ -49,7 +55,8 @@ def do_find_deleted_notes(all_anki_notes=None): guid = enLink.Guid title = clean_title(enLink.FullTitle) title_safe = str_safe(title) - if guid in find_guids: + + if guid in find_guids: find_title = find_guids[guid] find_title_safe = str_safe(find_title) if find_title_safe == title_safe: diff --git a/anknotes/logging.py b/anknotes/logging.py index efe3038..5ca399d 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -110,11 +110,11 @@ def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0 return False return True -def diffify(content): - for tag in ['div', 'ol', 'ul', 'li']: - content = content.replace("<" + tag, "\n<" + tag).replace("</%s>" % tag, "</%s>\n" % tag) - content = re.sub(r'[\r\n]+', '\n', content) - return content.splitlines() +def diffify(content, split=True): + for tag in [u'div', u'ol', u'ul', u'li', u'span']: + content = content.replace(u"<" + tag, u"\n<" + tag).replace(u"</%s>" % tag, u"</%s>\n" % tag) + content = re.sub(r'[\r\n]+', u'\n', content) + return content.splitlines() if split else content def pad_center(val, length=20, favor_right=True): val = str(val) @@ -268,19 +268,21 @@ def get_log_full_path(filename='', extension='log', as_url_link=False): filename += filename_suffix + '.' + extension filename = re.sub(r'[^\w\-_\.\\]', '_', filename) full_path = os.path.join(ANKNOTES.FOLDER_LOGS, filename) + if not os.path.exists(os.path.dirname(full_path)): + os.makedirs(os.path.dirname(full_path)) if as_url_link: return convert_filename_to_local_link(full_path) return full_path -def log_main2(**kwargs): - log(filename=ANKNOTES.LOG_DEFAULT_NAME, **kwargs) - -def log_test(**kwargs): - print '\n'.join(caller_names()) - #log(filename=None, **kwargs) - +def encode_log_text(content): + if not isinstance(content, str) and not isinstance(content, unicode): return content + try: + return content.encode('utf-8') + except Exception: + return content + # @clockit def log(content=None, filename=None, prefix='', clear=False, timestamp=True, extension='log', - replace_newline=None, do_print=False): + replace_newline=None, do_print=False, encode_text=True): if content is None: content = '' else: content = obj2log_simple(content) @@ -289,29 +291,15 @@ def log(content=None, filename=None, prefix='', clear=False, timestamp=True, ext content = content[1:] prefix = '\n' if filename and filename[0] is '+': - # filename = filename[1:] summary = " ** CROSS-POST TO %s: " % filename[1:] + content log(summary[:200]) full_path = get_log_full_path(filename, extension) - try: - content = content.encode('utf-8') - except Exception: - pass - if timestamp or replace_newline is True: - spacer = '\t'*6 - content = content.replace('\r\n', '\n').replace('\r', '\r'+spacer).replace('\n', '\n'+spacer) - if timestamp: - st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) - else: - st = '' - if not os.path.exists(os.path.dirname(full_path)): - os.makedirs(os.path.dirname(full_path)) - - # print "printing to %s: %s" % (full_path, content[:1000]) - with open(full_path, 'w+' if clear else 'a+') as fileLog: - print>> fileLog, prefix + ' ' + st + content - if do_print: - print prefix + ' ' + st + content + if encode_text: content = encode_log_text(content) + st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' + if timestamp or replace_newline is True: content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content) + contents = prefix + ' ' + st + content + with open(full_path, 'w+' if clear else 'a+') as fileLog: print>> fileLog, contents + if do_print: print contents def log_sql(content, **kwargs): log(content, 'sql', **kwargs) @@ -325,19 +313,18 @@ def print_dump(obj): content = content.replace(', ', ', \n ') content = content.replace('\r', '\r ').replace('\n', '\n ') - if isinstance(content, str): - content = unicode(content, 'utf-8') + content = encode_log_text(content) print content -def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, extension='.log'): +def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, extension='log'): content = pprint.pformat(obj, indent=4, width=80) try: content = content.decode('utf-8', 'ignore') except Exception: pass if filename and filename[0] is '+': summary = " ** CROSS-POST TO %s: " % filename[1:] + content log(summary[:200]) - filename = 'dump' + ('-%s' % filename) if filename else '' + filename = 'dump' + ('-%s' % filename if filename else '') full_path = get_log_full_path(filename, extension) st = '' if timestamp: @@ -350,8 +337,7 @@ def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, exte prefix = " **** Dumping %s" % title log(prefix) - if isinstance(content, str): - content = unicode(content, 'utf-8') + content = encode_log_text(content) try: prefix += '\r\n' @@ -377,6 +363,7 @@ def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, exte pass try: print>> fileLog, (u'\n <2> %s%s' % (st, content.encode('utf-8'))) + return except: pass try: diff --git a/anknotes/shared.py b/anknotes/shared.py index 0b05bc9..7ffc4d3 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -5,24 +5,28 @@ except ImportError: from sqlite3 import dbapi2 as sqlite +### Check if in Anki +try: + from aqt import mw + inAnki = True +except: inAnki = False + ### Anknotes Imports from anknotes.constants import * from anknotes.logging import * +from anknotes.html import * from anknotes.structs import * from anknotes.db import * -log('strting %s' % __name__, 'import') -from anknotes.html import * - ### Anki and Evernote Imports try: - from aqt import mw from aqt.qt import QIcon, QPixmap, QPushButton, QMessageBox from aqt.utils import tooltip from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ EDAMNotFoundException except: pass + # log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') def get_friendly_interval_string(lastImport): if not lastImport: return "" @@ -52,15 +56,15 @@ class EvernoteQueryLocationType: RelativeDay, RelativeWeek, RelativeMonth, RelativeYear, AbsoluteDate, AbsoluteDateTime = range(6) -def get_tag_names_to_import(tagNames, evernoteTags=None, evernoteTagsToDelete=None): - if not evernoteTags: - evernoteTags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE).split( - ",") if mw.col.conf.get(SETTINGS.DELETE_EVERNOTE_TAGS_TO_IMPORT, True) else [] - if not evernoteTagsToDelete: - evernoteTagsToDelete = mw.col.conf.get(SETTINGS.EVERNOTE_TAGS_TO_DELETE, "").split(",") - if isinstance(tagNames, dict): - return {k: v for k, v in tagNames.items() if v not in evernoteTags and v not in evernoteTagsToDelete} - return sorted([v for v in tagNames if v not in evernoteTags and v not in evernoteTagsToDelete], +def get_tag_names_to_import(tagNames, evernoteTags=None, evernoteTagsToDelete=None, keepEvernoteQueryTags=True): + if keepEvernoteQueryTags is None: keepEvernoteQueryTags = mw.col.conf.get(SETTINGS.DELETE_EVERNOTE_TAGS_TO_IMPORT, True) + if not keepEvernoteQueryTags: return {} if isinstance(tagNames, dict) else [] + if evernoteTags is None: evernoteTags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE).split(",") + if evernoteTagsToDelete is None: evernoteTagsToDelete = mw.col.conf.get(SETTINGS.EVERNOTE_TAGS_TO_DELETE, "").split(",") + tags_to_delete = evernoteTags + evernoteTagsToDelete + if isinstance(tagNames, dict): + return {k: v for k, v in tagNames.items() if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)} + return sorted([v for v in tagNames if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)], key=lambda s: s.lower()) def find_evernote_guids(content): @@ -82,7 +86,7 @@ def find_evernote_links(content): """ # .NET regex saved to regex.txt as 'Finding Evernote Links' content = replace_evernote_web_links(content) - regex_str = r'<a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)"(?:[^>]+)?>(?P<Title>.+?)</a>' + regex_str = r'<a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)"(?:[^>]+)?>(?P<title>.+?)</a>' ids = get_evernote_account_ids() if not ids.valid: match = re.search(regex_str, content) diff --git a/anknotes/stopwatch/__init__.py b/anknotes/stopwatch/__init__.py index e642e10..4030498 100644 --- a/anknotes/stopwatch/__init__.py +++ b/anknotes/stopwatch/__init__.py @@ -130,7 +130,7 @@ def remainingTimeStr(self): @property def progress(self): - return '%4s (%3s): @ %3s/%d. %4s of %4s remaining' % (self.__timetostr__(), self.percentCompleteStr, self.rateStr, self.__interval, self.remainingTimeStr, self.projectedTimeStr) + return '%4s (%3s): @ %3s/%d. %3s of %3s remain' % (self.__timetostr__(short=False), self.percentCompleteStr, self.rateStr, self.__interval, self.remainingTimeStr, self.projectedTimeStr) @property def active(self): @@ -243,12 +243,12 @@ def __timetostr__(self, total_seconds=None, short = True, pad=True): if total_seconds is None: total_seconds=self.elapsed total_seconds = int(round(total_seconds)) if total_seconds < 60: - return str(total_seconds) + [' sec', 's'][short] + return ['%ds','%2ds'][pad] % total_seconds m, s = divmod(total_seconds, 60) if short: - if total_seconds < 120: return '%d:%02d' % (m, s) - return ['%2dm'%m,'%-4s' % '%dm' % m][pad] - return '%dm %02d' % (m, s) + [' sec', 's'][short] + # if total_seconds < 120: return '%dm' % (m, s) + return ['%dm','%2dm'][pad] % m + return '%d:%02d' % (m, s) def __str__(self): """Nicely format the elapsed time diff --git a/anknotes/structs.py b/anknotes/structs.py index 5d3c736..0306f60 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -5,6 +5,7 @@ from anknotes.logging import log, str_safe, pad_center from anknotes.html import strip_tags from anknotes.enums import * +from anknotes.EvernoteNoteTitle import EvernoteNoteTitle # from evernote.edam.notestore.ttypes import NoteMetadata, NotesMetadataList @@ -41,12 +42,19 @@ def keys(self): def items(self): return [self.getAttribute(key) for key in self.__attr_order__] - + + def sqlUpdateQuery(self): + return "INSERT OR REPLACE INTO `%s`(%s) VALUES (%s)" % (self.__sql_table__, '`' + '`,`'.join(self.__sql_columns__) + '`', ', '.join(['?']*len(self.__sql_columns__))) + + def sqlSelectQuery(self, allColumns=True): + return "SELECT %s FROM %s WHERE %s = '%s'" % ( + '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) + def getFromDB(self, allColumns=True): query = "SELECT %s FROM %s WHERE %s = '%s'" % ( '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) ankDB().setrowfactory() - result = ankDB().first(query) + result = ankDB().first(self.sqlSelectQuery(allColumns)) if result: self.success = True self.setFromKeyedObject(result) @@ -96,8 +104,11 @@ def setFromKeyedObject(self, keyed_object, keys=None): keyed_object = keyed_object.groupdict() elif hasattr(keyed_object, 'keys'): keys = getattrcallable(keyed_object, 'keys') + elif hasattr(keyed_object, self.__sql_where__): + for key in self.keys(): + if hasattr(keyed_object, key): self.setAttribute(key, keyed_object) + return True else: - # keys_detected=False return False if keys is None: keys = keyed_object @@ -149,8 +160,7 @@ class EvernoteTag(EvernoteStruct): ParentGuid = "" __sql_columns__ = ["name", "parentGuid"] __sql_table__ = TABLES.EVERNOTE.TAGS - - + __attr__order__ = 'guid|name|parentGuid|updateSequenceNum' class EvernoteLink(EvernoteStruct): @@ -180,7 +190,7 @@ def Title(self, value): :param value: :type value : EvernoteNoteTitle.EvernoteNoteTitle | str | unicode :return: - """ + """ self.__title__ = anknotes.EvernoteNoteTitle.EvernoteNoteTitle(value) """:type : EvernoteNoteTitle.EvernoteNoteTitle""" diff --git a/anknotes_start_find_deleted_notes.py b/anknotes_start_find_deleted_notes.py index b731deb..347c2bf 100644 --- a/anknotes_start_find_deleted_notes.py +++ b/anknotes_start_find_deleted_notes.py @@ -6,4 +6,6 @@ if not isAnki: from anknotes import find_deleted_notes + from anknotes.db import ankDBSetLocal + ankDBSetLocal() find_deleted_notes.do_find_deleted_notes() \ No newline at end of file From 23dd99ffee7f34eeb84ad141829464e93ba7cfbb Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Tue, 22 Sep 2015 20:47:16 -0400 Subject: [PATCH 08/70] Squashed some bugs --- anknotes/Anki.py | 4 +- anknotes/AnkiNotePrototype.py | 104 +++++++++++++------------- anknotes/Controller.py | 12 +-- anknotes/EvernoteImporter.py | 1 - anknotes/EvernoteNoteFetcher.py | 17 +++-- anknotes/EvernoteNotePrototype.py | 4 +- anknotes/EvernoteNoteTitle.py | 4 +- anknotes/__main__.py | 3 +- anknotes/ankEvernote.py | 22 +++--- anknotes/find_deleted_notes.py | 1 - anknotes/logging.py | 33 +++++--- anknotes/setup.cfg | 3 - anknotes/structs.py | 21 +++--- anknotes_standAlone.py | 120 ------------------------------ anknotes_standAlone_template.py | 112 ---------------------------- anknotes_start_note_validation.py | 119 ++++++++++++++++++++++++++++- setup.cfg | 3 - 17 files changed, 240 insertions(+), 343 deletions(-) delete mode 100644 anknotes/setup.cfg delete mode 100644 anknotes_standAlone.py delete mode 100644 anknotes_standAlone_template.py delete mode 100644 setup.cfg diff --git a/anknotes/Anki.py b/anknotes/Anki.py index fac76a9..ee63d36 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -115,8 +115,8 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang if update: debug_fields = anki_note_prototype.Fields.copy() del debug_fields[FIELDS.CONTENT] - log_dump(debug_fields, - "- > UPDATE_evernote_notes → ADD_evernote_notes: anki_note_prototype: FIELDS ") + # log_dump(debug_fields, + # "- > UPDATE_evernote_notes → ADD_evernote_notes: anki_note_prototype: FIELDS ") if anki_note_prototype.update_note(): count_update += 1 else: if not -1 == anki_note_prototype.add_note(): count_update += 1 diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index a69a32d..97b7200 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -52,14 +52,6 @@ class Counts: _unprocessed_see_also_ = "" _log_update_if_unchanged_ = True - @property - def Content(self): - return self.Fields[FIELDS.CONTENT] - - @Content.setter - def Content(self, value): - self.Fields[FIELDS.CONTENT] = value - def __init__(self, anki=None, fields=None, tags=None, base_note=None, notebookGuid=None, count=-1, count_update=0, max_count=1, counts=None, light_processing=False, enNote=None): """ @@ -158,25 +150,25 @@ def process_note_content(self): def step_0_remove_evernote_css_attributes(): ################################### Step 0: Correct weird Evernote formatting - remove_style_attrs = '-webkit-text-size-adjust: auto|-webkit-text-stroke-width: 0px|background-color: rgb(255, 255, 255)|color: rgb(0, 0, 0)|font-family: Tahoma|font-size: medium;|font-style: normal|font-variant: normal|font-weight: normal|letter-spacing: normal|orphans: 2|text-align: -webkit-auto|text-indent: 0px|text-transform: none|white-space: normal|widows: 2|word-spacing: 0px'.replace( + remove_style_attrs = '-webkit-text-size-adjust: auto|-webkit-text-stroke-width: 0px|background-color: rgb(255, 255, 255)|color: rgb(0, 0, 0)|font-family: Tahoma|font-size: medium;|font-style: normal|font-variant: normal|font-weight: normal|letter-spacing: normal|orphans: 2|text-align: -webkit-auto|text-indent: 0px|text-transform: none|white-space: normal|widows: 2|word-spacing: 0px|word-wrap: break-word|-webkit-nbsp-mode: space|-webkit-line-break: after-white-space'.replace( '(', '\\(').replace(')', '\\)') # 'margin: 0px; padding: 0px 0px 0px 40px; ' - self.Content = re.sub(r' ?(%s);? ?' % remove_style_attrs, '', self.Content) - self.Content = self.Content.replace(' style=""', '') + self.Fields[FIELDS.CONTENT] = re.sub(r' ?(%s);? ?' % remove_style_attrs, '', self.Fields[FIELDS.CONTENT]) + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(' style=""', '') def step_1_modify_evernote_links(): ################################### Step 1: Modify Evernote Links # We need to modify Evernote's "Classic" Style Note Links due to an Anki bug with executing the evernote command with three forward slashes. # For whatever reason, Anki cannot handle evernote links with three forward slashes, but *can* handle links with two forward slashes. - self.Content = self.Content.replace("evernote:///", "evernote://") + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote:///", "evernote://") # Modify Evernote's "New" Style Note links that point to the Evernote website. Normally these links open the note using Evernote's web client. # The web client then opens the local Evernote executable. Modifying the links as below will skip this step and open the note directly using the local Evernote executable - self.Content = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', - r'evernote://view/\2/\1/\3/\3/', self.Content) + self.Fields[FIELDS.CONTENT] = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', + r'evernote://view/\2/\1/\3/\3/', self.Fields[FIELDS.CONTENT]) if self.light_processing: - self.Content = self.Content.replace("evernote://", "evernote:///") + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote://", "evernote:///") def step_2_modify_image_links(): ################################### Step 2: Modify Image Links @@ -186,40 +178,47 @@ def step_2_modify_image_links(): # Step 2.1: Modify HTML links to Dropbox images dropbox_image_url_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))(?P<QueryString>(?:\?dl=(?:0|1))?)' dropbox_image_src_subst = r'<a href="\g<URL>}\g<QueryString>}" shape="rect"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' - self.Content = re.sub(r'<a href="%s".*?>(?P<Title>.+?)</a>' % dropbox_image_url_regex, - dropbox_image_src_subst % "'\g<Title>'", self.Content) + self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="%s".*?>(?P<Title>.+?)</a>' % dropbox_image_url_regex, + dropbox_image_src_subst % "'\g<Title>'", self.Fields[FIELDS.CONTENT]) # Step 2.2: Modify Plain-text links to Dropbox images try: dropbox_image_url_regex = dropbox_image_url_regex.replace('(?P<QueryString>(?:\?dl=(?:0|1))?)', '(?P<QueryString>\?dl=(?:0|1))') - self.Content = re.sub(dropbox_image_url_regex, dropbox_image_src_subst % "From Plain-Text Link", self.Content) + self.Fields[FIELDS.CONTENT] = re.sub(dropbox_image_url_regex, dropbox_image_src_subst % "From Plain-Text Link", self.Fields[FIELDS.CONTENT]) except: - log_error("\nERROR processing note, Step 2.2. Content: %s" % self.Content) + log_error("\nERROR processing note, Step 2.2. Content: %s" % self.Fields[FIELDS.CONTENT]) # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link)" - self.Content = re.sub(r'<a href="(?P<URL>.+)"[^>]*>(?P<Title>\(Image Link.*\))</a>', + self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="(?P<URL>.+)"[^>]*>(?P<Title>\(Image Link.*\))</a>', r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', - self.Content) + self.Fields[FIELDS.CONTENT]) def step_3_occlude_text(): ################################### Step 3: Change white text to transparent # I currently use white text in Evernote to display information that I want to be initially hidden, but visible when desired by selecting the white text. # We will change the white text to a special "occluded" CSS class so it can be visible on the back of cards, and also so we can adjust the color for the front of cards when using night mode - self.Content = self.Content.replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') ################################### Step 4: Automatically Occlude Text in <<Double Angle Brackets>> - self.Content = re.sub(r'<<(.+?)>>', r'<<<span class="occluded">$1</span>>>', self.Content) + self.Fields[FIELDS.CONTENT] = re.sub(r'<<(.+?)>>', r'<<<span class="occluded">$1</span>>>', self.Fields[FIELDS.CONTENT]) def step_5_create_cloze_fields(): ################################### Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. - self.Content = re.sub(r'([^{]){(.+?)}([^}])', self.evernote_cloze_regex, self.Content) + self.Fields[FIELDS.CONTENT] = re.sub(r'([^{]){(.+?)}([^}])', self.evernote_cloze_regex, self.Fields[FIELDS.CONTENT]) def step_6_process_see_also_links(): ################################### Step 6: Process "See Also: " Links - see_also_match = regex_see_also().search(self.Content) - if not see_also_match: return - self.Content = self.Content.replace(see_also_match.group(0), see_also_match.group('Suffix')) + see_also_match = regex_see_also().search(self.Fields[FIELDS.CONTENT]) + + if not see_also_match: + if self.Fields[FIELDS.CONTENT].find("See Also") > -1: + raise ValueError + log("No See Also Content in " + self.Title.FullTitle + " \n" + self.Fields[FIELDS.CONTENT]) + return + + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group(0), see_also_match.group('Suffix')) + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<div><b><br/></b></div></en-note>', '</en-note>') see_also = see_also_match.group('SeeAlso') see_also_header = see_also_match.group('SeeAlsoHeader') see_also_header_stripme = see_also_match.group('SeeAlsoHeaderStripMe') @@ -229,13 +228,15 @@ def step_6_process_see_also_links(): self.Fields[FIELDS.SEE_ALSO] += "<br><br>\r\n" self.Fields[FIELDS.SEE_ALSO] += see_also if self.light_processing: - self.Content = self.Content.replace(see_also_match.group('Suffix'), self.Fields[FIELDS.SEE_ALSO] + see_also_match.group('Suffix')) - return + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group('Suffix'), self.Fields[FIELDS.SEE_ALSO] + see_also_match.group('Suffix')) + return + log_blank(); + log("Found see also match for %s\nContent: %s\n.\nSee Also: %s" % (self.Title.FullTitle, self.Fields[FIELDS.CONTENT], self.Fields[FIELDS.SEE_ALSO][:10])) self.process_note_see_also() if not FIELDS.CONTENT in self.Fields: return - self._unprocessed_content_ = self.Content - self._unprocessed_see_also_ = self.Fields[FIELDS.SEE_ALSO] + self._unprocessed_content_ = self.Fields[FIELDS.CONTENT] + self._unprocessed_see_also_ = self.Fields[FIELDS.SEE_ALSO] steps = [0, 1, 6] if self.light_processing else range(0,7) if self.light_processing and not ANKNOTES.NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING: steps.remove(0) @@ -245,8 +246,7 @@ def step_6_process_see_also_links(): step_2_modify_image_links() step_3_occlude_text() step_5_create_cloze_fields() - step_6_process_see_also_links() - + step_6_process_see_also_links() # TODO: Add support for extracting an 'Extra' field from the Evernote Note contents ################################### Note Processing complete. @@ -274,8 +274,8 @@ def model_id(self): if not self.ModelName: return None return long(self.Anki.models().byName(self.ModelName)['id']) - def process_note(self): - self.process_note_content() + def process_note(self): + self.process_note_content() if not self.light_processing: self.detect_note_model() @@ -369,11 +369,12 @@ def update_note_fields(self): # log_dump({'self.note.fields': self.note.fields, 'self.note._model.flds': self.note._model['flds']}, "- > UPDATE_NOTE → anki.notes.Note: _model: flds") field_updates = [] fields_updated = {} + log_blank(); for fld in self.note._model['flds']: if FIELDS.EVERNOTE_GUID in fld.get('name'): self.OriginalGuid = self.note.fields[fld.get('ord')].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') for field_to_update in fields_to_update: - if field_to_update in fld.get('name') and field_to_update in self.Fields: + if field_to_update == fld.get('name') and field_to_update in self.Fields: if field_to_update is FIELDS.CONTENT: fld_content_ord = fld.get('ord') try: @@ -399,19 +400,19 @@ def update_note_fields(self): self.Guid, self.Fields[FIELDS.TITLE], field_to_update, str(fld.get('ord')), len(self.note.fields))) raise - if len(field_updates) == 2: - if FIELDS.SEE_ALSO in fields_updated and FIELDS.CONTENT in fields_updated: - fc_test1 = (self._unprocessed_content_ == fields_updated[FIELDS.CONTENT]) - fc_test2 = (self._unprocessed_see_also_ == fields_updated[FIELDS.SEE_ALSO]) - fc_test = fc_test1 and fc_test2 - if fc_test: - field_updates = [] - self.log_update('(Detected See Also Contents)') - elif fc_test1: - del field_updates[0] - else: - log_dump([fc_test1, fc_test2, self._unprocessed_content_, '-' + fields_updated[FIELDS.CONTENT]], - 'AddUpdateNoteTest') + # if len(field_updates) == 2: + # if FIELDS.SEE_ALSO in fields_updated and FIELDS.CONTENT in fields_updated: + # fc_test1 = (self._unprocessed_content_ == fields_updated[FIELDS.CONTENT]) + # fc_test2 = (self._unprocessed_see_also_ == fields_updated[FIELDS.SEE_ALSO]) + # fc_test = fc_test1 and fc_test2 + # if fc_test: + # field_updates = [] + # self.log_update('(Detected See Also Contents)') + # elif fc_test1: + # del field_updates[0] + # else: + # log_dump([fc_test1, fc_test2, self._unprocessed_content_, '-' + fields_updated[FIELDS.CONTENT]], + # 'AddUpdateNoteTest') for update in field_updates: self.log_update(update) if not fld_content_ord is -1: @@ -421,8 +422,9 @@ def update_note_fields(self): if flag_changed: self.Changed = True return flag_changed - def update_note(self): + def update_note(self): # col = self.anki.collection() + assert self.Fields[FIELDS.CONTENT] == self.Fields[FIELDS.CONTENT] self.note = self.BaseNote self.logged = False if not self.BaseNote: @@ -431,6 +433,8 @@ def update_note(self): self.Changed = False self.update_note_tags() self.update_note_fields() + if 'See Also' in self.Fields[FIELDS.CONTENT]: + raise ValueError if not (self.Changed or self.update_note_deck()): if self._log_update_if_unchanged_: self.log_update("Not updating Note: The fields, tags, and deck are the same") diff --git a/anknotes/Controller.py b/anknotes/Controller.py index 6ab7d19..f6fb7b5 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -278,11 +278,11 @@ def proceed(self, auto_paging=False): self.evernoteImporter.proceed(auto_paging) def resync_with_local_db(self): - evernote_guids = get_all_local_db_guids() - result = self.evernote.create_evernote_notes(evernote_guids, use_local_db_only=True) - """:type: (int, int, list[EvernoteNote])""" - status, local_count, notes = result - number = self.anki.update_evernote_notes(notes, log_update_if_unchanged=False) + self.evernote.initialize_note_store() + evernote_guids = get_all_local_db_guids() + results = self.evernote.create_evernote_notes(evernote_guids, use_local_db_only=True) + """:type: EvernoteNoteFetcherResults""" + number = self.anki.update_evernote_notes(results.Notes, log_update_if_unchanged=False) tooltip = '%d Entries in Local DB<BR>%d Evernote Notes Created<BR>%d Anki Notes Successfully Updated' % ( - len(evernote_guids), local_count, number) + len(evernote_guids), results.Local, number) show_report('Resync with Local DB Complete', tooltip) diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index 0097df2..b3b731e 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -13,7 +13,6 @@ ### Anknotes Class Imports from anknotes.AnkiNotePrototype import AnkiNotePrototype -from anknotes.EvernoteNoteTitle import generateTOCTitle ### Anknotes Main Imports from anknotes.Anki import Anki diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index ce5d71e..3697011 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -16,6 +16,7 @@ def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): :type evernote: ankEvernote.Evernote """ + self.__reset_data__() self.results = EvernoteNoteFetcherResults() self.result = EvernoteNoteFetcherResult() self.api_calls = 0 @@ -23,8 +24,6 @@ def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): self.deleteQueryTags = True self.evernoteQueryTags = [] self.tagsToDelete = [] - self.tagNames = [] - self.tagGuids = [] self.use_local_db_only = use_local_db_only self.__update_sequence_number__ = -1 if evernote: self.evernote = evernote @@ -36,6 +35,11 @@ def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): self.__update_sequence_number__ = self.evernote.metadata[self.evernote_guid].updateSequenceNum self.getNote() + def __reset_data__(self): + self.tagNames = [] + self.tagGuids = [] + self.whole_note = None + def UpdateSequenceNum(self): if self.result.Note: return self.result.Note.UpdateSequenceNum @@ -77,9 +81,9 @@ def setNoteTags(self, tag_names=None, tag_guids=None): self.tagNames = [] self.tagGuids = [] return - if not tag_names: tag_names = self.tagNames - if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else self.whole_note.tagGuids - self.tagNames, self.tagGuids = self.evernote.get_matching_tag_data(tag_guids, tag_names) + if not tag_names: tag_names = self.tagNames if self.tagNames else self.result.Note.TagNames + if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else self.result.Note.TagGuids if self.result.Note else self.whole_note.tagGuids if self.whole_note else None + self.tagGuids, self.tagNames = self.evernote.get_matching_tag_data(tag_guids, tag_names) def addNoteFromServerToDB(self, whole_note=None, tag_names=None): """ @@ -159,7 +163,8 @@ def setNote(self, whole_note): self.addNoteFromServerToDB() def getNote(self, evernote_guid=None): - if evernote_guid: + self.__reset_data__() + if evernote_guid: self.result.Note = None self.evernote_guid = evernote_guid self.__update_sequence_number__ = self.evernote.metadata[ diff --git a/anknotes/EvernoteNotePrototype.py b/anknotes/EvernoteNotePrototype.py index e2903bf..22af90b 100644 --- a/anknotes/EvernoteNotePrototype.py +++ b/anknotes/EvernoteNotePrototype.py @@ -1,7 +1,7 @@ from anknotes.EvernoteNoteTitle import EvernoteNoteTitle from anknotes.html import generate_evernote_url, generate_evernote_link, generate_evernote_link_by_level from anknotes.structs import upperFirst, EvernoteAPIStatus -from anknotes.logging import log +from anknotes.logging import log, log_blank, log_error class EvernoteNotePrototype: ################## CLASS Note ################ @@ -56,7 +56,7 @@ def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid= db_note['tagNames'] = unicode(db_note['tagNames'], 'utf-8') for key in ['content', 'guid', 'notebookGuid', 'updateSequenceNum', 'tagNames', 'tagGuids']: if not key in db_note_keys: - log("Unable to find key %s in db note %s! \n%s" % (key, self.Title.FullTitle, db_note_keys)) + log_error("FATAL ERROR: Unable to find key %s in db note %s! \n%s" % (key, self.Title.FullTitle, db_note_keys)) log("Values: \n\n" + str({k: db_note[k] for k in db_note_keys }), 'EvernoteNotePrototypeInit') else: setattr(self, upperFirst(key), db_note[key]) diff --git a/anknotes/EvernoteNoteTitle.py b/anknotes/EvernoteNoteTitle.py index 0ed73ac..63a1468 100644 --- a/anknotes/EvernoteNoteTitle.py +++ b/anknotes/EvernoteNoteTitle.py @@ -2,14 +2,14 @@ ### Anknotes Shared Imports from anknotes.shared import * from sys import stderr -# log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') + + def generateTOCTitle(title): title = EvernoteNoteTitle.titleObjectToString(title).upper() for chr in u'αβδφḃ': title = title.replace(chr.upper(), chr) return title - class EvernoteNoteTitle: level = 0 __title__ = "" diff --git a/anknotes/__main__.py b/anknotes/__main__.py index f811ffe..1ea5e4b 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -92,7 +92,8 @@ def anknotes_profile_loaded(): # menu.see_also(3) # menu.see_also(4) # mw.progress.timer(20000, lambda : menu.find_deleted_notes(True), False) - menu.see_also([3,4]) + # menu.see_also([3,4]) + menu.resync_with_local_db() pass diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 2f4d7bf..fe0f18e 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -88,7 +88,7 @@ def __init__(self): def initialize_note_store(self): if self.noteStore: - return 0 + return EvernoteAPIStatus.Success api_action_str = u'trying to initialize the Evernote Client.' log_api("get_note_store") try: @@ -96,14 +96,14 @@ def initialize_note_store(self): except EDAMSystemException as e: if HandleEDAMRateLimitError(e, api_action_str): if DEBUG_RAISE_API_ERRORS: raise - return 1 + return EvernoteAPIStatus.RateLimitError raise except socket.error, v: if HandleSocketError(v, api_action_str): if DEBUG_RAISE_API_ERRORS: raise - return 2 + return EvernoteAPIStatus.SocketError raise - return 0 + return EvernoteAPIStatus.Success def validateNoteBody(self, noteBody, title="Note Body"): # timerFull = stopwatch.Timer() @@ -426,6 +426,7 @@ def check_tags_up_to_date(self): def update_tags_db(self): api_action_str = u'trying to update Evernote tags.' log_api("listTags") + try: tags = self.noteStore.listTags(self.token) """: type : list[evernote.edam.type.ttypes.Tag] """ @@ -443,34 +444,35 @@ def update_tags_db(self): if not hasattr(self, 'tag_data'): self.tag_data = {} for tag in tags: enTag = EvernoteTag(tag) - self.tag_data[tag.guid] = enTag + self.tag_data[enTag.Guid] = enTag data.append(enTag.items()) + ankDB().execute("DROP TABLE %s " % TABLES.EVERNOTE.TAGS) ankDB().InitTags(True) ankDB().executemany(enTag.sqlUpdateQuery(), data) + ankDB().commit() def set_tag_data(self): if not hasattr(self, 'tag_data'): self.tag_data = {x.guid: EvernoteTag(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS)} def get_missing_tags(self, current_tags, from_guids=True): - if instance(current_tags, list): current_tags = set(current_tags) + if isinstance(current_tags, list): current_tags = set(current_tags) return current_tags - set(self.tag_data.keys() if from_guids else [v.Name for k, v in self.tag_data.items()]) def get_matching_tag_data(self, tag_guids=None, tag_names=None): tagGuids = [] tagNames = [] self.set_tag_data() - current_tags = set(tags_original) - from_guids = True if tag_guids else False - tags_original = tag_guids or tag_names + assert tag_guids or tag_names + from_guids = True if (tag_guids is not None) else False + tags_original = tag_guids if from_guids else tag_names if self.get_missing_tags(tags_original, from_guids): self.update_tags_db() missing_tags = self.get_missing_tags(tags_original, from_guids) if missing_tags: log_error("FATAL ERROR: Tag %s(s) %s were not found on the Evernote Servers" % ('Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)))) raise EDAMNotFoundException() - if from_guids: tags_dict = {x: self.tag_data[x] for x in tags_original} else: tags_dict = {[k for k, v in tag_data.items() if v.Name is tag_name][0]: tag_name for tag_name in tags_original} tagNamesToImport = get_tag_names_to_import(tags_dict) diff --git a/anknotes/find_deleted_notes.py b/anknotes/find_deleted_notes.py index e4fe991..584d917 100644 --- a/anknotes/find_deleted_notes.py +++ b/anknotes/find_deleted_notes.py @@ -117,7 +117,6 @@ def do_find_deleted_notes(all_anki_notes=None): ] ] - # '<tr class=tr{:d}><td class=t1>{val[0]}</td><td class=t2><a href="{fn}">{title}</a></td><td class=t3>{val[1]}</td></tr>'.format(i, title=log[0], val=log[1], fn=convert_filename_to_local_link(log[1][1])) results = [ [ log[1], diff --git a/anknotes/logging.py b/anknotes/logging.py index 5ca399d..2600f08 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -243,7 +243,7 @@ def end_current_log(fn=None): else: _log_filename_history = _log_filename_history[:-1] -def get_log_full_path(filename='', extension='log', as_url_link=False): +def get_log_full_path(filename=None, extension='log', as_url_link=False): global _log_filename_history log_base_name = ANKNOTES.LOG_BASE_NAME filename_suffix = '' @@ -259,13 +259,15 @@ def get_log_full_path(filename='', extension='log', as_url_link=False): if filename is None: filename = _log_filename_history[-1] if _log_filename_history else ANKNOTES.LOG_ACTIVE if not filename: - filename = ANKNOTES.LOG_BASE_NAME + filename = log_base_name if not filename: filename = ANKNOTES.LOG_DEFAULT_NAME else: if filename[0] is '+': filename = filename[1:] filename = (log_base_name + '-' if log_base_name and log_base_name[-1] != '\\' else '') + filename - filename += filename_suffix + '.' + extension + + filename += filename_suffix + filename += ('.' if filename and filename[-1] is not '.' else '') + extension filename = re.sub(r'[^\w\-_\.\\]', '_', filename) full_path = os.path.join(ANKNOTES.FOLDER_LOGS, filename) if not os.path.exists(os.path.dirname(full_path)): @@ -293,19 +295,27 @@ def log(content=None, filename=None, prefix='', clear=False, timestamp=True, ext if filename and filename[0] is '+': summary = " ** CROSS-POST TO %s: " % filename[1:] + content log(summary[:200]) - full_path = get_log_full_path(filename, extension) - if encode_text: content = encode_log_text(content) + full_path = get_log_full_path(filename, extension) st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' - if timestamp or replace_newline is True: content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content) + if timestamp or replace_newline is True: + try: content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content) + except UnicodeDecodeError: + content = content.decode('utf-8') + content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content) contents = prefix + ' ' + st + content - with open(full_path, 'w+' if clear else 'a+') as fileLog: print>> fileLog, contents + if encode_text: content = encode_log_text(content) + with open(full_path, 'w+' if clear else 'a+') as fileLog: + try: print>> fileLog, contents + except UnicodeEncodeError: + contents = contents.encode('utf-8') + print>> fileLog, contents if do_print: print contents def log_sql(content, **kwargs): log(content, 'sql', **kwargs) def log_error(content, crossPost=True, **kwargs): - log(content, '+' if crossPost else '' + 'error', **kwargs) + log(content, ('+' if crossPost else '') + 'error', **kwargs) def print_dump(obj): @@ -315,12 +325,13 @@ def print_dump(obj): '\n ') content = encode_log_text(content) print content - + return content def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, extension='log'): content = pprint.pformat(obj, indent=4, width=80) try: content = content.decode('utf-8', 'ignore') except Exception: pass + content = content.replace("\\n", '\n').replace('\\r', '\r') if filename and filename[0] is '+': summary = " ** CROSS-POST TO %s: " % filename[1:] + content log(summary[:200]) @@ -377,11 +388,11 @@ def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, exte except: pass try: - print>> fileLog, (u'\n %s%s' % (st, "Error printing content: " + str_safe(content))) + print>> fileLog, (u'\n <5> %s%s' % (st, "Error printing content: " + str_safe(content))) return except: pass - print>> fileLog, (u'\n %s%s' % (st, "Error printing content: " + content[:10])) + print>> fileLog, (u'\n <6> %s%s' % (st, "Error printing content: " + content[:10])) def log_api(method, content='', **kwargs): diff --git a/anknotes/setup.cfg b/anknotes/setup.cfg deleted file mode 100644 index a4adf98..0000000 --- a/anknotes/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[pep8] -ignore = E701 -max-line-length = 160 \ No newline at end of file diff --git a/anknotes/structs.py b/anknotes/structs.py index 0306f60..0cda3f9 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -35,16 +35,13 @@ def __attr_from_key__(self, key): def keys(self): return self._valid_attributes_() - # if len(self.__attr_order__) == 0: - # self.__attr_order__ = self.__sql_columns__ + self.__sql_where__ - # self.__attr_order__.append(self.__sql_where__) - # return self.__attr_order__ - - def items(self): + + def items(self): return [self.getAttribute(key) for key in self.__attr_order__] def sqlUpdateQuery(self): - return "INSERT OR REPLACE INTO `%s`(%s) VALUES (%s)" % (self.__sql_table__, '`' + '`,`'.join(self.__sql_columns__) + '`', ', '.join(['?']*len(self.__sql_columns__))) + columns = self.__attr_order__ if self.__attr_order__ else self.__sql_columns__ + return "INSERT OR REPLACE INTO `%s`(%s) VALUES (%s)" % (self.__sql_table__, '`' + '`,`'.join(columns) + '`', ', '.join(['?']*len(columns))) def sqlSelectQuery(self, allColumns=True): return "SELECT %s FROM %s WHERE %s = '%s'" % ( @@ -73,7 +70,7 @@ def Where(self, value): def getAttribute(self, key, default=None, raiseIfInvalidKey=False): if not self.hasAttribute(key): if raiseIfInvalidKey: raise KeyError - return default + return default return getattr(self, self.__attr_from_key__(key)) def hasAttribute(self, key): @@ -97,7 +94,6 @@ def setFromKeyedObject(self, keyed_object, keys=None): :return: """ lst = self._valid_attributes_() - # keys_detected=True if keys or isinstance(keyed_object, dict): pass elif isinstance(keyed_object, type(re.search('', ''))): @@ -105,8 +101,8 @@ def setFromKeyedObject(self, keyed_object, keys=None): elif hasattr(keyed_object, 'keys'): keys = getattrcallable(keyed_object, 'keys') elif hasattr(keyed_object, self.__sql_where__): - for key in self.keys(): - if hasattr(keyed_object, key): self.setAttribute(key, keyed_object) + for key in self.keys(): + if hasattr(keyed_object, key): self.setAttribute(key, getattr(keyed_object, key)) return True else: return False @@ -158,9 +154,10 @@ class EvernoteNotebook(EvernoteStruct): class EvernoteTag(EvernoteStruct): ParentGuid = "" + UpdateSequenceNum = -1 __sql_columns__ = ["name", "parentGuid"] __sql_table__ = TABLES.EVERNOTE.TAGS - __attr__order__ = 'guid|name|parentGuid|updateSequenceNum' + __attr_order__ = 'guid|name|parentGuid|updateSequenceNum' class EvernoteLink(EvernoteStruct): diff --git a/anknotes_standAlone.py b/anknotes_standAlone.py deleted file mode 100644 index de0fd87..0000000 --- a/anknotes_standAlone.py +++ /dev/null @@ -1,120 +0,0 @@ -import os -from anknotes import stopwatch -import time -try: - from lxml import etree - eTreeImported=True -except: - eTreeImported=False -if eTreeImported: - - try: - from pysqlite2 import dbapi2 as sqlite - except ImportError: - from sqlite3 import dbapi2 as sqlite - - ### Anknotes Module Imports for Stand Alone Scripts - from anknotes import evernote as evernote - - ### Anknotes Shared Imports - from anknotes.shared import * - from anknotes.error import * - from anknotes.toc import TOCHierarchyClass - - ### Anknotes Class Imports - from anknotes.AnkiNotePrototype import AnkiNotePrototype - from anknotes.EvernoteNoteTitle import generateTOCTitle - - ### Anknotes Main Imports - from anknotes.Anki import Anki - from anknotes.ankEvernote import Evernote - # from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher - # from anknotes.EvernoteNotes import EvernoteNotes - # from anknotes.EvernoteNotePrototype import EvernoteNotePrototype - # from anknotes.EvernoteImporter import EvernoteImporter - # - # ### Evernote Imports - # from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec - # from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote - from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException - # from anknotes.evernote.api.client import EvernoteClient - - - ankDBSetLocal() - db = ankDB() - db.Init() - - failed_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) - pending_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 0" % TABLES.MAKE_NOTE_QUEUE) - success_queued_items = db.all("SELECT * FROM %s WHERE validation_status = -1 " % TABLES.MAKE_NOTE_QUEUE) - - currentLog = 'Successful' - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d SUCCESSFUL MAKE NOTE QUEUE ITEMS " % len(success_queued_items), 'MakeNoteQueue\\' + currentLog, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - - for result in success_queued_items: - line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' - line += result['title'] - log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=False) - - - currentLog = 'Failed' - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d FAILED MAKE NOTE QUEUE ITEMS " % len(failed_queued_items), 'MakeNoteQueue\\' + currentLog, clear=False, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - - - for result in failed_queued_items: - line = '%-60s ' % (result['title'] + ':') - line += (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW" - line += result['validation_result'] - log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) - log(result['contents'], 'MakeNoteQueue\\'+currentLog, timestamp=False) - log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) - - EN = Evernote() - - currentLog = 'Pending' - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d PENDING MAKE NOTE QUEUE ITEMS " % len(pending_queued_items), 'MakeNoteQueue\\' + currentLog, clear=False, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - - timerFull = stopwatch.Timer() - for result in pending_queued_items: - guid = result['guid'] - noteContents = result['contents'] - noteTitle = result['title'] - line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' - - - - - success, errors = EN.validateNoteContent(noteContents, noteTitle) - validation_status = 1 if success else -1 - - line = " SUCCESS! " if success else " FAILURE: " - line += ' ' if result['guid'] else ' NEW ' - # line += ' %-60s ' % (result['title'] + ':') - if not success: - errors = '\n * ' + '\n * '.join(errors) - log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - else: - errors = '\n'.join(errors) - - - sql = "UPDATE %s SET validation_status = %d, validation_result = '%s' WHERE " % (TABLES.MAKE_NOTE_QUEUE, validation_status, escape_text_sql(errors)) - if guid: - sql += "guid = '%s'" % guid - else: - sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) - - db.execute(sql) - - - timerFull.stop() - log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - - db.commit() - db.close() \ No newline at end of file diff --git a/anknotes_standAlone_template.py b/anknotes_standAlone_template.py deleted file mode 100644 index b2780be..0000000 --- a/anknotes_standAlone_template.py +++ /dev/null @@ -1,112 +0,0 @@ -import os -from anknotes import stopwatch -import time -try: - from lxml import etree - eTreeImported = True -except: - eTreeImported = False -if eTreeImported: - - try: - from pysqlite2 import dbapi2 as sqlite - except ImportError: - from sqlite3 import dbapi2 as sqlite - - # Anknotes Module Imports for Stand Alone Scripts - from anknotes import evernote as evernote - - # Anknotes Shared Imports - from anknotes.shared import * - from anknotes.error import * - from anknotes.toc import TOCHierarchyClass - - # Anknotes Class Imports - from anknotes.AnkiNotePrototype import AnkiNotePrototype - from anknotes.EvernoteNoteTitle import generateTOCTitle - - # Anknotes Main Imports - from anknotes.Anki import Anki - from anknotes.ankEvernote import Evernote - from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher - from anknotes.EvernoteNotes import EvernoteNotes - from anknotes.EvernoteNotePrototype import EvernoteNotePrototype - from anknotes.EvernoteImporter import EvernoteImporter - - # Evernote Imports - from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec - from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote - from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException - from anknotes.evernote.api.client import EvernoteClient - - ankDBSetLocal() - db = ankDB() - db.Init() - - failed_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) - pending_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 0" % TABLES.MAKE_NOTE_QUEUE) - success_queued_items = db.all("SELECT * FROM %s WHERE validation_status = -1 " % TABLES.MAKE_NOTE_QUEUE) - - currentLog = 'Successful' - log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d SUCCESSFUL MAKE NOTE QUEUE ITEMS " % len(success_queued_items), 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) - - for result in success_queued_items: - line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' - line += result['title'] - log(line, 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=False) - - currentLog = 'Failed' - log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d FAILED MAKE NOTE QUEUE ITEMS " % len(failed_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) - - for result in failed_queued_items: - line = '%-60s ' % (result['title'] + ':') - line += (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW" - line += result['validation_result'] - log(line, 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) - log("------------------------------------------------\n", 'MakeNoteQueue-' + currentLog, timestamp=False) - log(result['contents'], 'MakeNoteQueue-' + currentLog, timestamp=False) - log("------------------------------------------------\n", 'MakeNoteQueue-' + currentLog, timestamp=False) - - EN = Evernote() - - currentLog = 'Pending' - log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d PENDING MAKE NOTE QUEUE ITEMS " % len(pending_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) - - timerFull = stopwatch.Timer() - for result in pending_queued_items: - guid = result['guid'] - noteContents = result['contents'] - noteTitle = result['title'] - line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' - - success, errors = EN.validateNoteContent(noteContents, noteTitle) - validation_status = 1 if success else -1 - - line = " SUCCESS! " if success else " FAILURE: " - line += ' ' if result['guid'] else ' NEW ' - # line += ' %-60s ' % (result['title'] + ':') - if not success: - errors = '\n * ' + '\n * '.join(errors) - log(line, 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) - else: - errors = '\n'.join(errors) - - sql = "UPDATE %s SET validation_status = %d, validation_result = '%s' WHERE " % (TABLES.MAKE_NOTE_QUEUE, validation_status, escape_text_sql(errors)) - if guid: - sql += "guid = '%s'" % guid - else: - sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) - - db.execute(sql) - - timerFull.stop() - log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) - - db.commit() - db.close() diff --git a/anknotes_start_note_validation.py b/anknotes_start_note_validation.py index 7254a98..de0fd87 100644 --- a/anknotes_start_note_validation.py +++ b/anknotes_start_note_validation.py @@ -1,3 +1,120 @@ +import os +from anknotes import stopwatch +import time +try: + from lxml import etree + eTreeImported=True +except: + eTreeImported=False +if eTreeImported: -import anknotes_standAlone + try: + from pysqlite2 import dbapi2 as sqlite + except ImportError: + from sqlite3 import dbapi2 as sqlite + ### Anknotes Module Imports for Stand Alone Scripts + from anknotes import evernote as evernote + + ### Anknotes Shared Imports + from anknotes.shared import * + from anknotes.error import * + from anknotes.toc import TOCHierarchyClass + + ### Anknotes Class Imports + from anknotes.AnkiNotePrototype import AnkiNotePrototype + from anknotes.EvernoteNoteTitle import generateTOCTitle + + ### Anknotes Main Imports + from anknotes.Anki import Anki + from anknotes.ankEvernote import Evernote + # from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher + # from anknotes.EvernoteNotes import EvernoteNotes + # from anknotes.EvernoteNotePrototype import EvernoteNotePrototype + # from anknotes.EvernoteImporter import EvernoteImporter + # + # ### Evernote Imports + # from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec + # from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException + # from anknotes.evernote.api.client import EvernoteClient + + + ankDBSetLocal() + db = ankDB() + db.Init() + + failed_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) + pending_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 0" % TABLES.MAKE_NOTE_QUEUE) + success_queued_items = db.all("SELECT * FROM %s WHERE validation_status = -1 " % TABLES.MAKE_NOTE_QUEUE) + + currentLog = 'Successful' + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d SUCCESSFUL MAKE NOTE QUEUE ITEMS " % len(success_queued_items), 'MakeNoteQueue\\' + currentLog, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + + for result in success_queued_items: + line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' + line += result['title'] + log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=False) + + + currentLog = 'Failed' + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d FAILED MAKE NOTE QUEUE ITEMS " % len(failed_queued_items), 'MakeNoteQueue\\' + currentLog, clear=False, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + + + for result in failed_queued_items: + line = '%-60s ' % (result['title'] + ':') + line += (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW" + line += result['validation_result'] + log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) + log(result['contents'], 'MakeNoteQueue\\'+currentLog, timestamp=False) + log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) + + EN = Evernote() + + currentLog = 'Pending' + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d PENDING MAKE NOTE QUEUE ITEMS " % len(pending_queued_items), 'MakeNoteQueue\\' + currentLog, clear=False, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + + timerFull = stopwatch.Timer() + for result in pending_queued_items: + guid = result['guid'] + noteContents = result['contents'] + noteTitle = result['title'] + line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' + + + + + success, errors = EN.validateNoteContent(noteContents, noteTitle) + validation_status = 1 if success else -1 + + line = " SUCCESS! " if success else " FAILURE: " + line += ' ' if result['guid'] else ' NEW ' + # line += ' %-60s ' % (result['title'] + ':') + if not success: + errors = '\n * ' + '\n * '.join(errors) + log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + else: + errors = '\n'.join(errors) + + + sql = "UPDATE %s SET validation_status = %d, validation_result = '%s' WHERE " % (TABLES.MAKE_NOTE_QUEUE, validation_status, escape_text_sql(errors)) + if guid: + sql += "guid = '%s'" % guid + else: + sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) + + db.execute(sql) + + + timerFull.stop() + log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + + db.commit() + db.close() \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a4adf98..0000000 --- a/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[pep8] -ignore = E701 -max-line-length = 160 \ No newline at end of file From c4384121ec5538c83520afd0cdd51fe701192b46 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 01:57:39 -0400 Subject: [PATCH 09/70] Long-overdue update for README, moved README to root, and added my /extra/dev folder to the repo --- .gitignore | 2 +- README.md | 219 ++++++++++++++++++ anknotes/AnkiNotePrototype.py | 6 +- anknotes/EvernoteImporter.py | 4 +- anknotes/README.md | 23 -- anknotes/__main__.py | 10 +- anknotes/constants.py | 15 +- anknotes/extra/dev/Restart Anki.lnk | Bin 0 -> 2589 bytes anknotes/extra/dev/anknotes.developer | 0 .../extra/dev/anknotes.developer.automate | 0 .../extra/dev/anknotes_standAlone_template.py | 112 +++++++++ anknotes/extra/dev/anknotes_test.py | 209 +++++++++++++++++ anknotes/extra/dev/restart_anki.bat | 2 + 13 files changed, 560 insertions(+), 42 deletions(-) create mode 100644 README.md delete mode 100644 anknotes/README.md create mode 100644 anknotes/extra/dev/Restart Anki.lnk create mode 100644 anknotes/extra/dev/anknotes.developer create mode 100644 anknotes/extra/dev/anknotes.developer.automate create mode 100644 anknotes/extra/dev/anknotes_standAlone_template.py create mode 100644 anknotes/extra/dev/anknotes_test.py create mode 100644 anknotes/extra/dev/restart_anki.bat diff --git a/.gitignore b/.gitignore index e5f293c..6ef1230 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ anknotes/extra/logs/ anknotes/extra/powergrep/ anknotes/extra/local/ -anknotes/extra/testing/ +anknotes/extra/user/ anknotes/extra/anki_master autopep8 noteTest.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3d86d8 --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +# Anknotes (Evernote to Anki Importer) +**Forks and suggestions are very welcome.** + +##Outline +1. [Description] (#description) +1. [User Instructions] (#user-instructions) +1. [Current Features] (#current-features) +1. [Settings] (#settings) +1. [Details] (#details) + * [Templates] (#anki-templates) + * [Auto Import] (#auto-import) + * [Note Processing] (#note-processing-features) +1. [Beta Functions] (#beta-functions) +1. [See Also Footer Links] (#see-also-footer-links) +1. [Future Features] (#future-features) +1. [Developer Notes] (#developer-notes) + +## Description +An Anki plug-in for downloading Evernote notes to Anki directly from Anki. In addition to this core functionality, Anknotes can automatically modify Evernote notes, create new Evernote notes, and link related Evernote notes together. + +## User Instructions +1. Download everything, move it to your `Anki/addons` directory +1. Start Anki, and click the Menu Item `Anknotes → Import From Evernote` + - Optionally, you can customize [settings] (#settings) in the Anknotes tab in Anki's preferences +1. When you run it the first time a browser tab will open on the Evernote site asking you for access to your account + - When you click okay you are taken to a website where the OAuth verification key is displayed. You paste that key into the open Anki prompt and click okay. + - Note that for the first 24 hours after granting access, you have unlimited API usage. After that, Evernote applies rate limiting. + - So, sync everything immediately! + +## Current Features +#### Evernote Importing +- A rich set of [options] (#settings) will dynamically generate your query from tags, notebook, title, last updated date, or free text +- Free text can include any valid [Evernote query] (https://dev.evernote.com/doc/articles/search_grammar.php) +- [Auto Import] (#auto-import) is possible + +#### Anki Note Generation +- [Four types of Anki Notes] (#anki-templates) can be generated: + - Standard, Reversible, Reverse-Only, and Cloze +- [Post-process] (#note-processing-features) Evernote Notes with a few improvements + - [Fix Evernote note links] (#post-process-links) + - [Automatically embed images] (#post-process-images) + - [Occlude certain text] (#post-process-occlude) on fronts of Anki cards + - [Generate Cloze Fields] (#post-process-cloze) + - [Process a "See Also" Footer field] (#see-also-footer-links) for showing links to other Evernote notes +- See the [Beta Functions] (#beta-functions) section below for info on See Also Footer fields, Table of Contents notes, and Outline notes + +## Settings +#### Evernote Query +- You can enter any valid Evernote Query in the `Search Terms` field +- The check box before a given text field enables or disables that field +- Anknotes requires **all fields match** by default. + - You can use the `Match Any Terms` option to override this, but see the Evernote documentation on search for limitations + +### Pagination +- Controls the offset parameter of the Evernote search. +- Auto Pagination is recommended and on by default + +#### Anki Note Options +- Controls what is saved to Anki +- You can change the base Anki deck + - Anknotes can append the base deck with the Evernote note's Notebook Stack and Notebook Name + - Any colon will be converted to two colons, to enable Anki's sub-deck functionality +- You can change which Evernote tags are saved + +#### Note Updating +- By default, Anknotes will update existing Anki notes in place. This preserves all Anki statistics. +- You can also ignore existing notes, or delete and re-add existing notes (this will erase any Anki statistics) + +## Details +#### Anki Templates +- All use an advanced Anki template with customized content and CSS +- Reversible notes will generate a normal and reversed card for each note + - Add `#Reversible` tag to Evernote note before importing +- Reverse-only notes will only generate a reversed card + - Add `#Reverse-Only` tag to Evernote note before importing +- [Cloze notes] (#post-process-cloze) are automatically detected by Anknotes + +#### Auto Import +1. Automatically import on profile load + - Enable via Anknotes Menu + - Auto Import will be delayed if an import has occurred in the past 30 minutes +1. Automatically page through an Evernote query + - Enable via Anknotes Settings + - Evernote only returns 250 results per search, so queries with > 250 possible results require multiple searches + - If more than 10 API calls are made during a search, the next search is delayed by 15 minutes +1. Automatically import continuously + - Only configurable via source code at this time + - Enable Auto Import and Pagination as per above, and then modify `constants.py`, setting `PAGING_RESTART_WHEN_COMPLETE` to `True` + +#### Note Processing Features +1. Fix [Evernote Note Links] (https://dev.evernote.com/doc/articles/note_links.php) so that they can be opened in Anki + - Convert "New Style" Evernote web links to "Classic" Evernote in-app links so that any note links open directly in Evernote + - Convert all Evernote links to use two forward slashes instead of three to get around an Anki bug +1. Automatically embed images + - This is a workaround since Anki cannot import Evernote resources such as embedded images, PDF files, sounds, etc + - Anknotes will convert any of the following to embedded, linkable images: + - Any HTML Dropbox sharing link to an image `(https://www.dropbox.com/s/...)` + - Any Dropbox plain-text to an image (same as above, but plain-text links must end with `?dl=0` or `?dl=1`) + - Any HTML link with Link Text beginning with "Image Link", e.g.: `<a href='http://www.foo.com/bar'>Image Link #1</a>` +1. Occlude (hide) certain text on fronts of Anki cards + - Useful for displaying additional information but ensuring it only shows on backs of cards + - Anknotes converts any of the following to special text that will display in grey color, and only on the backs of cards: + - Any text with white foreground + - Any text within two brackets, such as `<<Hide Me>>` +1. Automatically generate [Cloze fields] (http://ankisrs.net/docs/manual.html#cloze) + - Any text with a single curly bracket will be converted into a cloze field + - E.g., two cloze fields are generated from: The central nervous system is made up of the `{brain}` and `{spinal cord}` + - If you want to generate a single cloze field (not increment the field #), insert a pound character `('#')` after the first curly bracket: + - E.g., a single cloze field is generated from: The central nervous system is made up of the `{brain}` and `{#spinal cord}` + +##Beta Functions +#### Note Creation +- Anknotes can create and upload/update existing Evernote notes +- Currently this is limited to creating new Auto TOC notes and modifying the See Also Footer field of existing notes +- Anknotes uses client-side validation to decrease API usage, but there is currently an issue with use of the validation library in Anki. + - So, Anknotes will execute this validation using an **external** script, not as an Anki addon + - Therefore, you must **manually** ensure that **Python** and the **lxml** module is installed on your system + - Alternately, disable validation: Edit `constants.py` and set `ENABLE_VALIDATION` to `False` + +#### Find Deleted/Orphaned Notes +- Anknotes is not intended for use as a sync client with Evernote (this may change in the future) +- Thus, notes deleted from the Evernote servers will not be deleted from Anki +- Use `Anknotes → Maintenance Tasks → Find Deleted Notes` to find and delete these notes from Anki + - You can also find notes in Evernote that don't exist in Anki + - First, you must create a "Table of Contents" note using the Evernote desktop application: + - In the Windows client, select ALL notes you want imported into Anki, and click the `Create Table of Contents Note` button on the right-sided panel + - Alternately, select 'Copy Note Links' and paste the content into a new Evernote Note. + - Export your Evernote note to `anknotes/extra/user/Table of Contents.enex` + +## "See Also" Footer Links +#### Concept +- You have topics (**Root Notes**) broken down into multiple sub-topics (**Sub Notes**) + - The Root Notes are too broad to be tested, and therefore not useful as Anki cards + - The Sub Notes are testable topics intended to be used as Anki cards +- Anknotes tries to link these related Sub Notes together so you can rapidly view related content in Evernote + +#### Terms +1. **Table of Contents (TOC) Notes** + - Primarily contain a hierarchical list of links to other notes +2. **Outline Notes** + - Primarily contain content itself of sub-notes + - E.g. a summary of sub-notes or full text of sub-notes + - Common usage scenario is creating a broad **Outline** style note when studying a topic, and breaking that down into multiple **Sub Notes** to use in Anki +3. **"See Also" Footer** Fields + - Primarily consist of links to TOC notes, Outline notes, or other Evernote notes +4. **Root Titles** and **Sub Notes** + - Sub Notes are notes with a colon in the title + - Root Title is the portion of the title before the first colon + +#### Integration +###### With Anki: +- The **"See Also" Footer** field is shown on the backs of Anki cards only, so having a descriptive link in here won't give away the correct answer +- The content itself of **TOC** and **Outline** notes are also viewable on the backs of Anki cards + +##### With Evernote: +- Anknotes can create new Evernote notes from automatically generated TOC notes +- Anknotes can update existing Evernote notes with modified See Also Footer fields + +#### Usage +###### Manual Usage: +- Add a new line to the end of your Evernote note that begins with `See Also`, and include relevant links after it +- Tag notes in Evernote before importing. + - Table of Contents (TOC) notes are designated by the `#TOC` tag. + - Outline notes are designed by the `#Outline` tag. + +###### Automated Usage: +- Anknotes can automatically create: + - Table of Contents Notes + - Created for **Root Titles** containing two or more Sub Notes + - In Anki, click the `Anknotes Menu → Process See Also Footer Links → Step 3: Create Auto TOC Notes`. + - Once the Auto TOC notes are generated, click `Steps 4 & 5` to upload the notes to Evernote + - See Also' Footer fields for displaying links to other Evernote notes + - Any links from other notes, including automatically generated TOC notes, are inserted into this field by Anknotes + - Creation of Outline notes from sub-notes or sub-notes from outline notes is a possible future feature + +#### Example: +Let's say we have nine **Sub Notes** titled `Diabetes: Symptoms`, `Diabetes: Treatment`, `Diabetes: Treatment: Types of Insulin`, and `Diabetes: Complications`, etc: +- Anknotes will generate a TOC note **`Diabetes`** with hierarchical links to all nine sub-notes as such: + + > DIABETES + > 1. Symptoms + > 2. Complications + > 1. Cardiovascular + > * Heart Attack Risk + > 2. Infectious + > 3. Ophthalmologic + > 3. Treatment + > * Types of Insulin + +- Anknotes can then insert a link to that TOC note in the 'See Also' Footer field of the sub notes +- This 'See Also' Footer field will display on the backs of Anki cards +- The TOC note's contents themselves will also be available on the backs of Anki cards + +## Future Features +- More robust options + - Move options from source code into GUI + - Allow enabling/disabling of beta functions like See Also fields + - Customize criteria for detecting see also fields +- Implement full sync with Evernote servers +- Import resources (e.g., images, sounds, etc) from Evernote notes +- Automatically create Anki sub-notes from a large Evernote note + +## Developer Notes +#### Anki Template / CSS Files: +- Template File Location: `/extra/ancillary/FrontTemplate.htm` +- CSS File Location: `/extra/ancillary/_AviAnkiCSS.css` +- Message Box CSS: `/extra/ancillary/QMessageBox.css` + +#### Anknotes Local Database +- Anknotes saves all Evernote notes, tags, and notebooks in the SQL database of the active Anki profile + - You may force a resync with the local Anknotes database via the menu: `Anknotes → Maintenance Tasks` + - You may force update of ancillary tag/notebook data via this menu +- Maps of see also footer links and Table of Contents notes are also saved here +- All Evernote note history is saved in a separate table. This is not currently used but may be helpful if data loss occurs or for future functionality + +#### Developer Functions +- If you are testing a new feature, you can automatically have Anki run that function when Anki starts. + - Simply add the method to `__main__.py` under the comment `Add a function here and it will automatically run on profile load` + - Also, create the folder `/anknotes/extra/dev` and add files `anknotes.developer` and `anknotes.developer.automate` \ No newline at end of file diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 97b7200..8f0451e 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -129,7 +129,7 @@ def evernote_cloze_regex(self, match): self.__cloze_count__ += 1 if self.__cloze_count__ == 0: self.__cloze_count__ = 1 - return "%s{{c%d::%s}}%s" % (match.group(1), self.__cloze_count__, matchText, match.group(2)) + return "%s{{c%d::%s}}%s" % (match.group(1), self.__cloze_count__, matchText, match.group(3)) def process_note_see_also(self): if not FIELDS.SEE_ALSO in self.Fields or not FIELDS.EVERNOTE_GUID in self.Fields: @@ -177,7 +177,7 @@ def step_2_modify_image_links(): # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page # Step 2.1: Modify HTML links to Dropbox images dropbox_image_url_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))(?P<QueryString>(?:\?dl=(?:0|1))?)' - dropbox_image_src_subst = r'<a href="\g<URL>}\g<QueryString>}" shape="rect"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' + dropbox_image_src_subst = r'<a href="\g<URL>}\g<QueryString>}"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="%s".*?>(?P<Title>.+?)</a>' % dropbox_image_url_regex, dropbox_image_src_subst % "'\g<Title>'", self.Fields[FIELDS.CONTENT]) @@ -190,7 +190,7 @@ def step_2_modify_image_links(): log_error("\nERROR processing note, Step 2.2. Content: %s" % self.Fields[FIELDS.CONTENT]) # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link)" - self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="(?P<URL>.+)"[^>]*>(?P<Title>\(Image Link.*\))</a>', + self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="(?P<URL>.+?)"[^>]*>(?P<Title>\(Image Link.*\))</a>', r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', self.Fields[FIELDS.CONTENT]) diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index b3b731e..5980ca3 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -264,7 +264,7 @@ def proceed_autopage(self): self.auto_page_callback() return True elif col.conf.get(EVERNOTE.PAGING_RESTART_WHEN_COMPLETE, True): - restart = EVERNOTE.PAGING_RESTART_INTERVAL + restart = max(EVERNOTE.PAGING_RESTART_INTERVAL, 60*15) restart_title = " > Restarting Auto Paging" restart_msg = "All %d notes have been processed and EVERNOTE.PAGING_RESTART_WHEN_COMPLETE is TRUE<BR>" % \ self.MetadataProgress.Total @@ -287,7 +287,7 @@ def proceed_autopage(self): suffix = "<BR>Not delaying as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS" % ( self.ImportProgress.APICallCount, EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS) else: - restart = EVERNOTE.PAGING_TIMER_INTERVAL + restart = max(EVERNOTE.PAGING_TIMER_INTERVAL, 60*10) suffix = "<BR>Delaying Auto Paging: Per EVERNOTE.PAGING_TIMER_INTERVAL, " if not self.forceAutoPage: diff --git a/anknotes/README.md b/anknotes/README.md deleted file mode 100644 index 0204cc8..0000000 --- a/anknotes/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Evernote2Anki Importer (beta) -**Forks and suggestions are very welcome.** - -## Description -An Anki plug-in aiming for syncing evernote account with anki directly from anki. It aims to replace a Java standalone application [available here] (https://code.google.com/p/evernote2anki/) -Very rudimentary for the moment. I wait for suggestions according to the needs of evernote/anki users. - -## Users : How to use it -- download everything, move it to your Anki/addons directory -- start Anki, fill in the Infromation in the prefrences tap and then press Import from Evernote --When you run it the first Time a browser tab will open on the evernote site asking you for access to your account -- when you click ok you are taken to a website where the oauth verification key is displayed you paste that key into the open anki windows and click ok with that you are set. - -## Features and further development -####Current feature : -- Import all the notes from evernote with selected tags -- Possibility to choose the name of the deck, as well as the default tag in anki (but should not be changed) -- Does not import twice a card (only new cards are imported) -- - A window allowing the user to change the options (instead of manual edit of options.cfg) - -####Desirable new features (?) : - -- Updating anki cards accordingly the edit of evernote notes. diff --git a/anknotes/__main__.py b/anknotes/__main__.py index 1ea5e4b..8f08ab9 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -31,7 +31,7 @@ def import_timer_toggle(): importDelay = 0 if lastImport: td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) - minimum = timedelta(seconds=EVERNOTE.IMPORT_TIMER_INTERVAL / 1000) + minimum = timedelta(seconds=max(EVERNOTE.IMPORT_TIMER_INTERVAL, 20*60)) if td < minimum: importDelay = (minimum - td).total_seconds() * 1000 if importDelay is 0: @@ -82,10 +82,12 @@ def anknotes_profile_loaded(): if ANKNOTES.ENABLE_VALIDATION and ANKNOTES.AUTOMATE_VALIDATION: menu.upload_validated_notes(True) import_timer_toggle() - ''' - For testing purposes only: - ''' if ANKNOTES.DEVELOPER_MODE_AUTOMATE: + ''' + For testing purposes only! + Add a function here and it will automatically run on profile load + You must create the files 'anknotes.developer' and 'anknotes.developer.automate' in the /extra/dev/ folder + ''' # resync_with_local_db() # menu.see_also() # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) diff --git a/anknotes/constants.py b/anknotes/constants.py index 7bb9afb..25d75e9 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -10,7 +10,8 @@ class ANKNOTES: FOLDER_ANCILLARY = os.path.join(FOLDER_EXTRA, 'ancillary') FOLDER_GRAPHICS = os.path.join(FOLDER_EXTRA, 'graphics') FOLDER_LOGS = os.path.join(FOLDER_EXTRA, 'logs') - FOLDER_TESTING = os.path.join(FOLDER_EXTRA, 'testing') + FOLDER_DEVELOPER = os.path.join(FOLDER_EXTRA, 'dev') + FOLDER_USER = os.path.join(FOLDER_EXTRA, 'user') LOG_BASE_NAME = '' LOG_DEFAULT_NAME = 'anknotes' LOG_MAIN = LOG_DEFAULT_NAME @@ -20,7 +21,7 @@ class ANKNOTES: CSS = u'_AviAnkiCSS.css' QT_CSS_QMESSAGEBOX = os.path.join(FOLDER_ANCILLARY, 'QMessageBox.css') ENML_DTD = os.path.join(FOLDER_ANCILLARY, 'enml2.dtd') - TABLE_OF_CONTENTS_ENEX = os.path.join(FOLDER_TESTING, "Table of Contents.enex") + TABLE_OF_CONTENTS_ENEX = os.path.join(FOLDER_USER, "Table of Contents.enex") VALIDATION_SCRIPT = os.path.join(FOLDER_ADDONS_ROOT, 'anknotes_start_note_validation.py') # anknotes-standAlone.py') FIND_DELETED_NOTES_SCRIPT = os.path.join(FOLDER_ADDONS_ROOT, 'anknotes_start_find_deleted_notes.py') # anknotes-standAlone.py') LOG_FDN_ANKI_ORPHANS = 'Find Deleted Notes\\' @@ -37,8 +38,8 @@ class ANKNOTES: EVERNOTE_CONSUMER_KEY = "holycrepe" EVERNOTE_IS_SANDBOXED = False DATE_FORMAT = '%Y-%m-%d %H:%M:%S' - DEVELOPER_MODE = (os.path.isfile(os.path.join(FOLDER_TESTING, 'anknotes.developer'))) - DEVELOPER_MODE_AUTOMATE = (os.path.isfile(os.path.join(FOLDER_TESTING, 'anknotes.developer.automate'))) + DEVELOPER_MODE = (os.path.isfile(os.path.join(FOLDER_DEVELOPER, 'anknotes.developer'))) + DEVELOPER_MODE_AUTOMATE = (os.path.isfile(os.path.join(FOLDER_DEVELOPER, 'anknotes.developer.automate'))) UPLOAD_AUTO_TOC_NOTES = True # Set False if debugging note creation AUTO_TOC_NOTES_MAX = -1 # Set to -1 for unlimited ENABLE_VALIDATION = True @@ -74,10 +75,6 @@ class FIELDS: UPDATE_SEQUENCE_NUM] SEE_ALSO_FIELDS_ORD = LIST.index(SEE_ALSO) + 1 - - - - class DECKS: DEFAULT = "Evernote" TOC_SUFFIX = "::See Also::TOC" @@ -95,7 +92,7 @@ class TAG: # Note that Evernote's API documentation says not to run API calls to findNoteMetadata with any less than a 15 minute interval PAGING_RESTART_INTERVAL = 60 * 15 - # Auto Paging is probably only useful in the first 24 hours, when API usage is unlimited, or when executing a search that is likely to have most of the notes up-to-date locally + # Auto Paging is probably only useful in the first 24 hours, when API usage is unlimited, or when executing a search that is likely to have most of the notes up-to-date locally # To keep from overloading Evernote's servers, and flagging our API key, I recommend pausing 5-15 minutes in between searches, the higher the better. PAGING_TIMER_INTERVAL = 60 * 15 PAGING_RESTART_DELAY_MINIMUM_API_CALLS = 10 diff --git a/anknotes/extra/dev/Restart Anki.lnk b/anknotes/extra/dev/Restart Anki.lnk new file mode 100644 index 0000000000000000000000000000000000000000..d6508287ac1da6f74275e40283ce525ebf91fbe1 GIT binary patch literal 2589 zcmcguYfM~K5FQFGMF>K9gc{nSjbsUSSzcwAv}R%HUaYkc9`@mFV9Ty$1$LX=EiR3z zv`SL2R1<tulPa24q7j=KrHNXDX&Pf}8lp*k#+o+$Q*B72wZ_=s_nmuzh1B4$p1U)5 z&YW|;Gc#xA+}$K1ahZ$2MaC5u_m8qv@*rn9E${YCPQ;C6x6FAvF~${fpxD+IpDC;A zf2o_uyISc_d+WQUQfRXsT`ongN<DU-DMzJPNjKer@^onq`$A#yN|GeV<YzbCSxMAe zF=I^4T5PTosZ>%=I9Hk_EItW=i#WmC(d)!hDkWkC7Uc(I4SL052bE%8rff9#Ez(|f zq)An`A7jR@f4VE*r<S`$_i~$fvY6I?F*UAAy2TIOGI`cS%GY|V=~7eYYJOzHJ~-7G zgU2!A?VpENtx!)Ncu{I3C~dMwx?p`kBLB;q3aJnq5OQSmj5pitWl|OBfN7O8QT#l) zNZV%T{;ij|VIo;f^L{lXRoJydd6X$lCR(AE<tr19*BcBVxDU?Yok`{h@vdGb%#s)^ zih=Dz;z8uS@F}1?$(HRVX0~=PoR;7TbOeGCA5g1JRO(4YJ#&gl<phbOhzu)1nr}pb zN-G1vjUY}u@f@i)k*%6#*e1ScB;=8GCaOgn8Kj=5E1MR9&$soD!)JjM1zrf~%#258 zV!6X%6Brrs2X?_4BReeGn}bE#4wW4%6yj`7VmaEz3t4f7brHlfAiJQ;#=As$#I<?S zu%WT3s$pNTwYsWFq`|~4(sai(_O;gASxfAY@;m}-L$JEVjENt)z7ui;yXAs<%r_&+ z{u&Tl6qF8PP9M&-{Fa8dpfpwG>8Jy}CfK&8%NK}*b>@+79KSw&V!8^jZiYn<Av~T? zqy@u%>rM}LUj{>Tn8xGzZzCPzLW1fs#j7I)SyK9KN`YfWdg^d-HnG^7n-F@;Vd~i5 zu);WoLLhU`hfrunV(PGhA@kvog0R^ydm(!<ryji^v`mUFJO%>wJ(%CF)?1-%Lz%ZC zf(YmER)!KS90~b6U4~5l@j2h*a|gS%{XgB>eWm972jP^K)Skz#q`&c0q~OHwV*?9~ z^N5gS!%VczARcEj`V+5xbL;ulC)}eScJ|fA`@i7WIS>+OI8koEkAl@+SqaT-OO#Yu zi|z}^3zr#%AbjwsViKf&IYv(&`TObFHS_z6%{za&IfW@UO(l>M%)T+)D}tZ&_8Kp6 zh%!DtjyDIU)K8|SD1q=$1@;Vrngm~tLtorj-+0Y-`UjWvYa@S6WE=}WeqTTIUXZK; ztpm+H?mV&Ffg~%S0cbRaMqV1&>i%?--Sx>o8AJI;y3R8X5;%cIvIrZ&i$Uceev&Aa z;cZ9Hp)PbAbQS2ADt$4|i=$lSfW{8uW5=^rX>Irj7#~sYa{xp`5|?aAc{%=P*HO>q zd%wL{pFdsnOdS<sDtK&j6ufliD3tA7!p0@)x{Jk7H!9ch{Mfz((E{7``%g`3_oN-a zzNq!k>%YFo%({yc7z#oybV+b-7U*sYPO@>A`VT@SJ`BdFlLDFpG!y9zbhfCf<wxjA zWOp7uHLUeERBxWRG1iG%XCqoF1t)0wZw~usL3HHpC)zCQU-~-zqYE-#_kGU9JJ74L zq9YH3?)+W0@C^&IRAX4^#L4Wgn$f}CwIj7o>#_CGLo1+f=Zsp==)fyUl%-ekIqYAc Ca_T|= literal 0 HcmV?d00001 diff --git a/anknotes/extra/dev/anknotes.developer b/anknotes/extra/dev/anknotes.developer new file mode 100644 index 0000000..e69de29 diff --git a/anknotes/extra/dev/anknotes.developer.automate b/anknotes/extra/dev/anknotes.developer.automate new file mode 100644 index 0000000..e69de29 diff --git a/anknotes/extra/dev/anknotes_standAlone_template.py b/anknotes/extra/dev/anknotes_standAlone_template.py new file mode 100644 index 0000000..b2780be --- /dev/null +++ b/anknotes/extra/dev/anknotes_standAlone_template.py @@ -0,0 +1,112 @@ +import os +from anknotes import stopwatch +import time +try: + from lxml import etree + eTreeImported = True +except: + eTreeImported = False +if eTreeImported: + + try: + from pysqlite2 import dbapi2 as sqlite + except ImportError: + from sqlite3 import dbapi2 as sqlite + + # Anknotes Module Imports for Stand Alone Scripts + from anknotes import evernote as evernote + + # Anknotes Shared Imports + from anknotes.shared import * + from anknotes.error import * + from anknotes.toc import TOCHierarchyClass + + # Anknotes Class Imports + from anknotes.AnkiNotePrototype import AnkiNotePrototype + from anknotes.EvernoteNoteTitle import generateTOCTitle + + # Anknotes Main Imports + from anknotes.Anki import Anki + from anknotes.ankEvernote import Evernote + from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher + from anknotes.EvernoteNotes import EvernoteNotes + from anknotes.EvernoteNotePrototype import EvernoteNotePrototype + from anknotes.EvernoteImporter import EvernoteImporter + + # Evernote Imports + from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec + from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException + from anknotes.evernote.api.client import EvernoteClient + + ankDBSetLocal() + db = ankDB() + db.Init() + + failed_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) + pending_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 0" % TABLES.MAKE_NOTE_QUEUE) + success_queued_items = db.all("SELECT * FROM %s WHERE validation_status = -1 " % TABLES.MAKE_NOTE_QUEUE) + + currentLog = 'Successful' + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d SUCCESSFUL MAKE NOTE QUEUE ITEMS " % len(success_queued_items), 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + + for result in success_queued_items: + line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' + line += result['title'] + log(line, 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=False) + + currentLog = 'Failed' + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d FAILED MAKE NOTE QUEUE ITEMS " % len(failed_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + + for result in failed_queued_items: + line = '%-60s ' % (result['title'] + ':') + line += (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW" + line += result['validation_result'] + log(line, 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + log("------------------------------------------------\n", 'MakeNoteQueue-' + currentLog, timestamp=False) + log(result['contents'], 'MakeNoteQueue-' + currentLog, timestamp=False) + log("------------------------------------------------\n", 'MakeNoteQueue-' + currentLog, timestamp=False) + + EN = Evernote() + + currentLog = 'Pending' + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d PENDING MAKE NOTE QUEUE ITEMS " % len(pending_queued_items), 'MakeNoteQueue-' + currentLog, clear=False, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + + timerFull = stopwatch.Timer() + for result in pending_queued_items: + guid = result['guid'] + noteContents = result['contents'] + noteTitle = result['title'] + line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' + + success, errors = EN.validateNoteContent(noteContents, noteTitle) + validation_status = 1 if success else -1 + + line = " SUCCESS! " if success else " FAILURE: " + line += ' ' if result['guid'] else ' NEW ' + # line += ' %-60s ' % (result['title'] + ':') + if not success: + errors = '\n * ' + '\n * '.join(errors) + log(line, 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + else: + errors = '\n'.join(errors) + + sql = "UPDATE %s SET validation_status = %d, validation_result = '%s' WHERE " % (TABLES.MAKE_NOTE_QUEUE, validation_status, escape_text_sql(errors)) + if guid: + sql += "guid = '%s'" % guid + else: + sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) + + db.execute(sql) + + timerFull.stop() + log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue-' + currentLog, timestamp=False, do_print=True) + + db.commit() + db.close() diff --git a/anknotes/extra/dev/anknotes_test.py b/anknotes/extra/dev/anknotes_test.py new file mode 100644 index 0000000..d1e04de --- /dev/null +++ b/anknotes/extra/dev/anknotes_test.py @@ -0,0 +1,209 @@ +import os +import re +from HTMLParser import HTMLParser + +PATH = os.path.dirname(os.path.abspath(__file__)) +ANKNOTES_TEMPLATE_FRONT = 'FrontTemplate.htm' +MODEL_EVERNOTE_DEFAULT = 'evernote_note' +MODEL_EVERNOTE_REVERSIBLE = 'evernote_note_reversible' +MODEL_EVERNOTE_REVERSE_ONLY = 'evernote_note_reverse_only' +MODEL_EVERNOTE_CLOZE = 'evernote_note_cloze' +MODEL_TYPE_CLOZE = 1 + +TEMPLATE_EVERNOTE_DEFAULT = 'EvernoteReview' +TEMPLATE_EVERNOTE_REVERSED = 'EvernoteReviewReversed' +TEMPLATE_EVERNOTE_CLOZE = 'EvernoteReviewCloze' +FIELD_TITLE = 'title' +FIELD_CONTENT = 'content' +FIELD_SEE_ALSO = 'See Also' +FIELD_EXTRA = 'Extra' +FIELD_EVERNOTE_GUID = 'Evernote GUID' + +EVERNOTE_TAG_REVERSIBLE = '#Reversible' +EVERNOTE_TAG_REVERSE_ONLY = '#Reversible_Only' + +TABLE_SEE_ALSO = "anknotes_see_also" +TABLE_TOC = "anknotes_toc" + +SETTING_KEEP_EVERNOTE_TAGS_DEFAULT_VALUE = True +SETTING_EVERNOTE_TAGS_TO_IMPORT_DEFAULT_VALUE = "#Anki_Import" +SETTING_DEFAULT_ANKI_TAG_DEFAULT_VALUE = "#Evernote" +SETTING_DEFAULT_ANKI_DECK_DEFAULT_VALUE = "Evernote" + +SETTING_DELETE_EVERNOTE_TAGS_TO_IMPORT = 'anknotesDeleteEvernoteTagsToImport' +SETTING_UPDATE_EXISTING_NOTES = 'anknotesUpdateExistingNotes' +SETTING_EVERNOTE_AUTH_TOKEN = 'anknotesEvernoteAuthToken' +SETTING_KEEP_EVERNOTE_TAGS = 'anknotesKeepEvernoteTags' +SETTING_EVERNOTE_TAGS_TO_IMPORT = 'anknotesEvernoteTagsToImport' +# Deprecated +# SETTING_DEFAULT_ANKI_TAG = 'anknotesDefaultAnkiTag' +SETTING_DEFAULT_ANKI_DECK = 'anknotesDefaultAnkiDeck' + +evernote_cloze_count = 0 + + +class MLStripper(HTMLParser): + def __init__(self): + self.reset() + self.fed = [] + + def handle_data(self, d): + self.fed.append(d) + + def get_data(self): + return ''.join(self.fed) + + +def strip_tags(html): + s = MLStripper() + s.feed(html) + return s.get_data() + + +class AnkiNotePrototype: + fields = {} + tags = [] + evernote_tags_to_import = [] + model_name = MODEL_EVERNOTE_DEFAULT + + def __init__(self, fields, tags, evernote_tags_to_import=list()): + self.fields = fields + self.tags = tags + self.evernote_tags_to_import = evernote_tags_to_import + + self.process_note() + + @staticmethod + def evernote_cloze_regex(match): + global evernote_cloze_count + matchText = match.group(1) + if matchText[0] == "#": + matchText = matchText[1:] + else: + evernote_cloze_count += 1 + if evernote_cloze_count == 0: + evernote_cloze_count = 1 + + # print "Match: Group #%d: %s" % (evernote_cloze_count, matchText) + return "{{c%d::%s}}" % (evernote_cloze_count, matchText) + + def process_note_see_also(self): + if not FIELD_SEE_ALSO in self.fields or not FIELD_EVERNOTE_GUID in self.fields: + return + + note_guid = self.fields[FIELD_EVERNOTE_GUID] + # mw.col.db.execute("CREATE TABLE IF NOT EXISTS %s(id INTEGER PRIMARY KEY, note_guid TEXT, uid INTEGER, shard TEXT, guid TEXT, html TEXT, text TEXT ) " % TABLE_SEE_ALSO) + # mw.col.db.execute("CREATE TABLE IF NOT EXISTS %s(id INTEGER PRIMARY KEY, note_guid TEXT, uid INTEGER, shard TEXT, guid TEXT, title TEXT ) " % TABLE_TOC) + # mw.col.db.execute("DELETE FROM %s WHERE note_guid = '%s' " % (TABLE_SEE_ALSO, note_guid)) + # mw.col.db.execute("DELETE FROM %s WHERE note_guid = '%s' " % (TABLE_TOC, note_guid)) + + + print "Running See Also" + iter = re.finditer( + r'<a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+)/(?P<shard>s\d+)/(?P<guid>[\w\-]+)/(?P=guid)/?)"(?: shape="rect")?>(?P<Title>.+?)</a>', + self.fields[FIELD_SEE_ALSO]) + for match in iter: + title_text = strip_tags(match.group('Title')) + print "Link: %s: %s" % (match.group('guid'), title_text) + # for id, ivl in mw.col.db.execute("select id, ivl from cards limit 3"): + + + + + # .NET Regex: <a href="(?<URL>evernote:///?view/(?<uid>[\d]+)/(?<shard>s\d+)/(?<guid>[\w\-]+)/\k<guid>/?)"(?: shape="rect")?>(?<Title>.+?)</a> + # links_match + + def process_note_content(self): + if not FIELD_CONTENT in self.fields: + return + content = self.fields[FIELD_CONTENT] + ################################## Step 1: Modify Evernote Links + # We need to modify Evernote's "Classic" Style Note Links due to an Anki bug with executing the evernote command with three forward slashes. + # For whatever reason, Anki cannot handle evernote links with three forward slashes, but *can* handle links with two forward slashes. + content = content.replace("evernote:///", "evernote://") + + # Modify Evernote's "New" Style Note links that point to the Evernote website. Normally these links open the note using Evernote's web client. + # The web client then opens the local Evernote executable. Modifying the links as below will skip this step and open the note directly using the local Evernote executable + content = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', + r'evernote://view/\2/\1/\3/\3/', content) + + ################################## Step 2: Modify Image Links + # Currently anknotes does not support rendering images embedded into an Evernote note. + # As a work around, this code will convert any link to an image on Dropbox, to an embedded <img> tag. + # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page + # Step 2.1: Modify HTML links to Dropbox images + dropbox_image_url_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))(?P<QueryString>\?dl=(?:0|1))?' + dropbox_image_src_subst = r'<a href="\g<URL>}\g<QueryString>}" shape="rect"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' + content = re.sub(r'<a href="%s".*?>(?P<Title>.+?)</a>' % dropbox_image_url_regex, + dropbox_image_src_subst % "'\g<Title>'", content) + + # Step 2.2: Modify Plain-text links to Dropbox images + content = re.sub(dropbox_image_url_regex, dropbox_image_src_subst % "From Plain-Text Link", content) + + # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link)" + content = re.sub(r'<a href="(?P<URL>.+)"[^>]+>(?P<Title>\(Image Link.*\))</a>', + r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', + content) + + ################################## Step 3: Change white text to transparent + # I currently use white text in Evernote to display information that I want to be initially hidden, but visible when desired by selecting the white text. + # We will change the white text to a special "occluded" CSS class so it can be visible on the back of cards, and also so we can adjust the color for the front of cards when using night mode + content = content.replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') + + ################################## Step 4: Automatically Occlude Text in <<Double Angle Brackets>> + content = re.sub(r'<<(.+?)>>', r'<<<span class="occluded">$1</span>>>', content) + + ################################## Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. + content = re.sub(r'{(.+?)}', self.evernote_cloze_regex, content) + + ################################## Step 6: Process "See Also: " Links + # .NET regex: (?<PrefixStrip><div><b><span style="color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);"><br/></span></b></div>)?(?<SeeAlso>(?<SeeAlsoPrefix><div>)(?<SeeAlsoHeader><span style="color: rgb\(45, 79, 201\);"><b>See Also:(?: )?</b></span>|<b><span style="color: rgb\(45, 79, 201\);">See Also:</span></b>)(?<SeeAlsoContents>.+))(?<Suffix></en-note>) + see_also_match = re.search( + r'(?:<div><b><span style="color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);"><br/></span></b></div>)?(?P<SeeAlso>(?:<div>)(?:<span style="color: rgb\(45, 79, 201\);"><b>See Also:(?: )?</b></span>|<b><span style="color: rgb\(45, 79, 201\);">See Also:</span></b>) ?(?P<SeeAlsoLinks>.+))(?P<Suffix></en-note>)', + content) + # see_also_match = re.search(r'(?P<PrefixStrip><div><b><span style="color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);"><br/></span></b></div>)?(?P<SeeAlso>(?:<div>)(?P<SeeAlsoHeader><span style="color: rgb\(45, 79, 201\);">(?:See Also|<b>See Also:</b>).*?</span>).+?)(?P<Suffix></en-note>)', content) + + if see_also_match: + content = content.replace(see_also_match.group(0), see_also_match.group('Suffix')) + self.fields[FIELD_SEE_ALSO] = see_also_match.group('SeeAlso') + self.process_note_see_also() + + ################################## Note Processing complete. + self.fields[FIELD_CONTENT] = content + + def process_note(self): + self.model_name = MODEL_EVERNOTE_DEFAULT + # Process Note Content + self.process_note_content() + + # Dynamically determine Anki Card Type + if FIELD_CONTENT in self.fields and "{{c1::" in self.fields[FIELD_CONTENT]: + self.model_name = MODEL_EVERNOTE_CLOZE + elif EVERNOTE_TAG_REVERSIBLE in self.tags: + self.model_name = MODEL_EVERNOTE_REVERSIBLE + if True: # if mw.col.conf.get(SETTING_DELETE_EVERNOTE_TAGS_TO_IMPORT, True): + self.tags.remove(EVERNOTE_TAG_REVERSIBLE) + elif EVERNOTE_TAG_REVERSE_ONLY in self.tags: + model_name = MODEL_EVERNOTE_REVERSE_ONLY + if True: # if mw.col.conf.get(SETTING_DELETE_EVERNOTE_TAGS_TO_IMPORT, True): + self.tags.remove(EVERNOTE_TAG_REVERSE_ONLY) + + # Remove Evernote Tags to Import + if True: # if mw.col.conf.get(SETTING_DELETE_EVERNOTE_TAGS_TO_IMPORT, True): + for tag in self.evernote_tags_to_import: + self.tags.remove(tag) + + +def test_anki(title, guid, filename=""): + if not filename: filename = title + fields = {FIELD_TITLE: title, FIELD_CONTENT: file(os.path.join(PATH, filename + ".enex"), 'r').read(), + FIELD_EVERNOTE_GUID: guid} + tags = ['NoTags', 'NoTagsToRemove'] + en_tags = ['NoTagsToRemove'] + return AnkiNotePrototype(fields, tags, en_tags) + + +title = "Test title" +content = file(os.path.join(PATH, ANKNOTES_TEMPLATE_FRONT), 'r').read() +anki_note_prototype = test_anki("CNS Lesions Presentations Neuromuscular", '301a42d6-7ce5-4850-a365-cd1f0e98939d') +print "EN GUID: " + anki_note_prototype.fields[FIELD_EVERNOTE_GUID] diff --git a/anknotes/extra/dev/restart_anki.bat b/anknotes/extra/dev/restart_anki.bat new file mode 100644 index 0000000..8c833ef --- /dev/null +++ b/anknotes/extra/dev/restart_anki.bat @@ -0,0 +1,2 @@ +taskkill /f /im anki.exe +"C:\Program Files (x86)\Anki\anki.exe" \ No newline at end of file From 91294ac8f89c92ffa416cdf2930a1770e5667936 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 03:42:31 -0400 Subject: [PATCH 10/70] Re-merge changes --- .gitignore | 3 - anknotes/EvernoteImporter.py | 309 +++ anknotes/EvernoteNoteFetcher.py | 174 ++ anknotes/EvernoteNotePrototype.py | 137 ++ anknotes/EvernoteNoteTitle.py | 229 +++ anknotes/EvernoteNotes.py | 429 +++++ anknotes/__main__.py | 494 ++--- anknotes/enum/LICENSE | 32 + anknotes/enum/README | 2 + anknotes/enum/__init__.py | 790 ++++++++ anknotes/enum/doc/enum.rst | 725 +++++++ anknotes/enum/enum.py | 790 ++++++++ anknotes/enum/test_enum.py | 1690 +++++++++++++++++ anknotes/error.py | 59 + anknotes/evernote/edam/notestore/ttypes.py | 1 + .../ancillary/FrontTemplate-Processed.htm | 123 ++ anknotes/extra/ancillary/FrontTemplate.htm | 142 ++ anknotes/extra/ancillary/_AviAnkiCSS.css | 356 ++++ anknotes/extra/ancillary/_attributes.css | 115 ++ anknotes/extra/ancillary/enml2.dtd | 592 ++++++ anknotes/{ => extra/ancillary}/index.html | 0 anknotes/extra/ancillary/regex-see_also2.txt | 21 + anknotes/extra/ancillary/sorting.txt | 49 + anknotes/extra/graphics/Evernote.ico | Bin 0 -> 293950 bytes anknotes/extra/graphics/Evernote.png | Bin 0 -> 73017 bytes anknotes/extra/graphics/evernote_artcore.ico | Bin 0 -> 270398 bytes anknotes/extra/graphics/evernote_artcore.png | Bin 0 -> 87462 bytes anknotes/extra/graphics/evernote_metro.ico | Bin 0 -> 370070 bytes anknotes/extra/graphics/evernote_metro.png | Bin 0 -> 5648 bytes .../graphics/evernote_metro_reflected.ico | Bin 0 -> 370070 bytes .../graphics/evernote_metro_reflected.png | Bin 0 -> 8452 bytes anknotes/extra/graphics/evernote_web.ico | Bin 0 -> 370070 bytes anknotes/extra/graphics/evernote_web.png | Bin 0 -> 18786 bytes anknotes/menu.py | 309 +++ anknotes/oauth2/__init__.py | 1 + anknotes/settings.py | 695 +++++++ anknotes/shared.py | 115 ++ anknotes/structs.py | 686 +++++++ ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 33 - anknotes/thrift/TSCons.py~HEAD | 33 - ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 34 - anknotes/thrift/TSerialization.py~HEAD | 34 - ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 154 -- anknotes/thrift/Thrift.py~HEAD | 154 -- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 20 - anknotes/thrift/__init__.py~HEAD | 20 - ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 72 - anknotes/thrift/protocol/TBase.py~HEAD | 72 - ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 259 --- .../thrift/protocol/TBinaryProtocol.py~HEAD | 259 --- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 395 ---- .../thrift/protocol/TCompactProtocol.py~HEAD | 395 ---- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 404 ---- anknotes/thrift/protocol/TProtocol.py~HEAD | 404 ---- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 20 - anknotes/thrift/protocol/__init__.py~HEAD | 20 - ...c~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 1219 ------------ anknotes/thrift/protocol/fastbinary.c~HEAD | 1219 ------------ ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 82 - anknotes/thrift/server/THttpServer.py~HEAD | 82 - ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 310 --- .../thrift/server/TNonblockingServer.py~HEAD | 310 --- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 125 -- .../thrift/server/TProcessPoolServer.py~HEAD | 125 -- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 274 --- anknotes/thrift/server/TServer.py~HEAD | 274 --- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 20 - anknotes/thrift/server/__init__.py~HEAD | 20 - ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 161 -- anknotes/thrift/transport/THttpClient.py~HEAD | 161 -- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 176 -- anknotes/thrift/transport/TSSLSocket.py~HEAD | 176 -- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 163 -- anknotes/thrift/transport/TSocket.py~HEAD | 163 -- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 331 ---- anknotes/thrift/transport/TTransport.py~HEAD | 331 ---- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 219 --- anknotes/thrift/transport/TTwisted.py~HEAD | 219 --- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 261 --- .../thrift/transport/TZlibTransport.py~HEAD | 261 --- ...y~155d40b1f21ee8336f1c8d81dbef09df4cb39236 | 20 - anknotes/thrift/transport/__init__.py~HEAD | 20 - anknotes/toc.py | 333 ++++ anknotes/version.py | 4 +- 84 files changed, 9013 insertions(+), 9896 deletions(-) create mode 100644 anknotes/EvernoteImporter.py create mode 100644 anknotes/EvernoteNoteFetcher.py create mode 100644 anknotes/EvernoteNotePrototype.py create mode 100644 anknotes/EvernoteNoteTitle.py create mode 100644 anknotes/EvernoteNotes.py create mode 100644 anknotes/enum/LICENSE create mode 100644 anknotes/enum/README create mode 100644 anknotes/enum/__init__.py create mode 100644 anknotes/enum/doc/enum.rst create mode 100644 anknotes/enum/enum.py create mode 100644 anknotes/enum/test_enum.py create mode 100644 anknotes/error.py create mode 100644 anknotes/extra/ancillary/FrontTemplate-Processed.htm create mode 100644 anknotes/extra/ancillary/FrontTemplate.htm create mode 100644 anknotes/extra/ancillary/_AviAnkiCSS.css create mode 100644 anknotes/extra/ancillary/_attributes.css create mode 100644 anknotes/extra/ancillary/enml2.dtd rename anknotes/{ => extra/ancillary}/index.html (100%) create mode 100644 anknotes/extra/ancillary/regex-see_also2.txt create mode 100644 anknotes/extra/ancillary/sorting.txt create mode 100644 anknotes/extra/graphics/Evernote.ico create mode 100644 anknotes/extra/graphics/Evernote.png create mode 100644 anknotes/extra/graphics/evernote_artcore.ico create mode 100644 anknotes/extra/graphics/evernote_artcore.png create mode 100644 anknotes/extra/graphics/evernote_metro.ico create mode 100644 anknotes/extra/graphics/evernote_metro.png create mode 100644 anknotes/extra/graphics/evernote_metro_reflected.ico create mode 100644 anknotes/extra/graphics/evernote_metro_reflected.png create mode 100644 anknotes/extra/graphics/evernote_web.ico create mode 100644 anknotes/extra/graphics/evernote_web.png create mode 100644 anknotes/menu.py create mode 100644 anknotes/settings.py create mode 100644 anknotes/shared.py create mode 100644 anknotes/structs.py delete mode 100644 anknotes/thrift/TSCons.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/TSCons.py~HEAD delete mode 100644 anknotes/thrift/TSerialization.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/TSerialization.py~HEAD delete mode 100644 anknotes/thrift/Thrift.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/Thrift.py~HEAD delete mode 100644 anknotes/thrift/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/__init__.py~HEAD delete mode 100644 anknotes/thrift/protocol/TBase.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/protocol/TBase.py~HEAD delete mode 100644 anknotes/thrift/protocol/TBinaryProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/protocol/TBinaryProtocol.py~HEAD delete mode 100644 anknotes/thrift/protocol/TCompactProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/protocol/TCompactProtocol.py~HEAD delete mode 100644 anknotes/thrift/protocol/TProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/protocol/TProtocol.py~HEAD delete mode 100644 anknotes/thrift/protocol/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/protocol/__init__.py~HEAD delete mode 100644 anknotes/thrift/protocol/fastbinary.c~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/protocol/fastbinary.c~HEAD delete mode 100644 anknotes/thrift/server/THttpServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/server/THttpServer.py~HEAD delete mode 100644 anknotes/thrift/server/TNonblockingServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/server/TNonblockingServer.py~HEAD delete mode 100644 anknotes/thrift/server/TProcessPoolServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/server/TProcessPoolServer.py~HEAD delete mode 100644 anknotes/thrift/server/TServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/server/TServer.py~HEAD delete mode 100644 anknotes/thrift/server/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/server/__init__.py~HEAD delete mode 100644 anknotes/thrift/transport/THttpClient.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/transport/THttpClient.py~HEAD delete mode 100644 anknotes/thrift/transport/TSSLSocket.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/transport/TSSLSocket.py~HEAD delete mode 100644 anknotes/thrift/transport/TSocket.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/transport/TSocket.py~HEAD delete mode 100644 anknotes/thrift/transport/TTransport.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/transport/TTransport.py~HEAD delete mode 100644 anknotes/thrift/transport/TTwisted.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/transport/TTwisted.py~HEAD delete mode 100644 anknotes/thrift/transport/TZlibTransport.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/transport/TZlibTransport.py~HEAD delete mode 100644 anknotes/thrift/transport/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 delete mode 100644 anknotes/thrift/transport/__init__.py~HEAD create mode 100644 anknotes/toc.py diff --git a/.gitignore b/.gitignore index 1ae0750..6ef1230 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -<<<<<<< HEAD anknotes/extra/logs/ anknotes/extra/powergrep/ anknotes/extra/local/ @@ -8,8 +7,6 @@ autopep8 noteTest.py *.bk -======= ->>>>>>> master ################# ## Eclipse ################# diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py new file mode 100644 index 0000000..5980ca3 --- /dev/null +++ b/anknotes/EvernoteImporter.py @@ -0,0 +1,309 @@ +# -*- coding: utf-8 -*- +### Python Imports +import socket + +try: + from pysqlite2 import dbapi2 as sqlite +except ImportError: + from sqlite3 import dbapi2 as sqlite + +### Anknotes Shared Imports +from anknotes.shared import * +from anknotes.error import * + +### Anknotes Class Imports +from anknotes.AnkiNotePrototype import AnkiNotePrototype + +### Anknotes Main Imports +from anknotes.Anki import Anki +from anknotes.ankEvernote import Evernote +from anknotes.EvernoteNotes import EvernoteNotes +from anknotes.EvernoteNotePrototype import EvernoteNotePrototype + +try: from anknotes import settings +except: pass + +### Evernote Imports +from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec, NoteMetadata, NotesMetadataList +from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote +from anknotes.evernote.edam.error.ttypes import EDAMSystemException + +### Anki Imports +try: from aqt import mw +except: pass + +DEBUG_RAISE_API_ERRORS = False + + +class EvernoteImporter: + forceAutoPage = False + auto_page_callback = None + """:type : lambda""" + anki = None + """:type : Anki""" + evernote = None + """:type : Evernote""" + updateExistingNotes = UpdateExistingNotes.UpdateNotesInPlace + ManualGUIDs = None + @property + def ManualMetadataMode(self): + return (self.ManualGUIDs is not None and len(self.ManualGUIDs) > 0) + + def __init(self): + self.updateExistingNotes = mw.col.conf.get(SETTINGS.UPDATE_EXISTING_NOTES, + UpdateExistingNotes.UpdateNotesInPlace) + self.ManualGUIDs = None + + def override_evernote_metadata(self): + guids = self.ManualGUIDs + self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) + self.MetadataProgress.Total = len(guids) + self.MetadataProgress.Current = min(self.MetadataProgress.Total - self.MetadataProgress.Offset, 250) + result = NotesMetadataList() + result.totalNotes = len(guids) + result.updateCount = -1 + result.startIndex = self.MetadataProgress.Offset + result.notes = [] + """:type : list[NoteMetadata]""" + for i in range(self.MetadataProgress.Offset, self.MetadataProgress.Completed): + result.notes.append(NoteMetadata(guids[i])) + self.MetadataProgress.loadResults(result) + self.evernote.metadata = self.MetadataProgress.NotesMetadata + return True + + def get_evernote_metadata(self): + """ + :returns: Metadata Progress Instance + :rtype : EvernoteMetadataProgress) + """ + query = settings.generate_evernote_query() + evernote_filter = NoteFilter(words=query, ascending=True, order=NoteSortOrder.UPDATED) + self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) + spec = NotesMetadataResultSpec(includeTitle=False, includeUpdated=False, includeUpdateSequenceNum=True, + includeTagGuids=True, includeNotebookGuid=True) + api_action_str = u'trying to search for note metadata' + log_api("findNotesMetadata", "[Offset: %3d]: Query: '%s'" % (self.MetadataProgress.Offset, query)) + try: + result = self.evernote.noteStore.findNotesMetadata(self.evernote.token, evernote_filter, + self.MetadataProgress.Offset, + EVERNOTE.METADATA_QUERY_LIMIT, spec) + """ + :type: NotesMetadataList + """ + except EDAMSystemException as e: + if HandleEDAMRateLimitError(e, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + self.MetadataProgress.Status = EvernoteAPIStatus.RateLimitError + return False + raise + except socket.error, v: + if HandleSocketError(v, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + self.MetadataProgress.Status = EvernoteAPIStatus.SocketError + return False + raise + + self.MetadataProgress.loadResults(result) + self.evernote.metadata = self.MetadataProgress.NotesMetadata + log(" - Metadata Results: %s" % self.MetadataProgress.Summary, timestamp=False) + return True + + def update_in_anki(self, evernote_guids): + """ + :rtype : EvernoteNoteFetcherResults + """ + Results = self.evernote.create_evernote_notes(evernote_guids) + if self.ManualMetadataMode: + self.evernote.check_notebooks_up_to_date() + self.anki.notebook_data = self.evernote.notebook_data + Results.Imported = self.anki.update_evernote_notes(Results.Notes) + return Results + + def import_into_anki(self, evernote_guids): + """ + :rtype : EvernoteNoteFetcherResults + """ + Results = self.evernote.create_evernote_notes(evernote_guids) + if self.ManualMetadataMode: + self.evernote.check_notebooks_up_to_date() + self.anki.notebook_data = self.evernote.notebook_data + Results.Imported = self.anki.add_evernote_notes(Results.Notes) + return Results + + def check_note_sync_status(self, evernote_guids): + """ + Check for already existing, up-to-date, local db entries by Evernote GUID + :param evernote_guids: List of GUIDs + :return: List of Already Existing Evernote GUIDs + :rtype: list[str] + """ + notes_already_up_to_date = [] + for evernote_guid in evernote_guids: + db_usn = ankDB().scalar("SELECT updateSequenceNum FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, + evernote_guid) + if not self.evernote.metadata[evernote_guid].updateSequenceNum: + server_usn = 'N/A' + else: + server_usn = self.evernote.metadata[evernote_guid].updateSequenceNum + if evernote_guid in self.anki.usns: + current_usn = self.anki.usns[evernote_guid] + if current_usn == str(server_usn): + log_info = None # 'ANKI NOTE UP-TO-DATE' + notes_already_up_to_date.append(evernote_guid) + elif str(db_usn) == str(server_usn): + log_info = 'DATABASE ENTRY UP-TO-DATE' + else: + log_info = 'NO COPIES UP-TO-DATE' + else: + current_usn = 'N/A' + log_info = 'NO ANKI USN EXISTS' + if log_info: + if not self.evernote.metadata[evernote_guid].updateSequenceNum: + log_info += ' (Unable to find Evernote Metadata) ' + log(" > USN check for note '%s': %s: db/current/server = %s,%s,%s" % ( + evernote_guid, log_info, str(db_usn), str(current_usn), str(server_usn)), 'usn') + return notes_already_up_to_date + + def proceed(self, auto_paging=False): + self.proceed_start(auto_paging) + self.proceed_find_metadata(auto_paging) + self.proceed_import_notes() + self.proceed_autopage() + + def proceed_start(self, auto_paging=False): + col = self.anki.collection() + lastImport = col.conf.get(SETTINGS.EVERNOTE_LAST_IMPORT, None) + col.conf[SETTINGS.EVERNOTE_LAST_IMPORT] = datetime.now().strftime(ANKNOTES.DATE_FORMAT) + col.setMod() + col.save() + lastImportStr = get_friendly_interval_string(lastImport) + if lastImportStr: + lastImportStr = ' [LAST IMPORT: %s]' % lastImportStr + log("! > Starting Evernote Import: Page #%d: %-60s%s" % ( + self.currentPage, settings.generate_evernote_query(), lastImportStr)) + log( + "-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------", + timestamp=False) + if not auto_paging: + if not hasattr(self.evernote, 'noteStore'): + log(" > Note store does not exist. Aborting.") + return False + self.evernote.getNoteCount = 0 + + def proceed_find_metadata(self, auto_paging=False): + global latestEDAMRateLimit, latestSocketError + + + # anki_note_ids = self.anki.get_anknotes_note_ids() + # anki_evernote_guids = self.anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) + + if self.ManualMetadataMode: + self.override_evernote_metadata() + else: + self.get_evernote_metadata() + if self.MetadataProgress.Status == EvernoteAPIStatus.RateLimitError: + m, s = divmod(latestEDAMRateLimit, 60) + show_report(" > Error: Delaying Operation", + "Over the rate limit when searching for Evernote metadata<BR>Evernote requested we wait %d:%02d min" % ( + m, s), delay=5) + mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(auto_paging), False) + return False + elif self.MetadataProgress.Status == EvernoteAPIStatus.SocketError: + show_report(" > Error: Delaying Operation:", + "%s when searching for Evernote metadata" % + latestSocketError['friendly_error_msg'], "We will try again in 30 seconds", delay=5) + mw.progress.timer(30000, lambda: self.proceed(auto_paging), False) + return False + + self.ImportProgress = EvernoteImportProgress(self.anki, self.MetadataProgress) + self.ImportProgress.loadAlreadyUpdated( + [] if self.ManualMetadataMode else self.check_note_sync_status(self.ImportProgress.GUIDs.Server.Existing.All)) + log(" - " + self.ImportProgress.Summary + "\n", timestamp=False) + + def proceed_import_notes(self): + self.anki.start_editing() + self.ImportProgress.processResults(self.import_into_anki(self.ImportProgress.GUIDs.Server.New)) + if self.updateExistingNotes is UpdateExistingNotes.UpdateNotesInPlace: + self.ImportProgress.processUpdateInPlaceResults( + self.update_in_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) + elif self.updateExistingNotes is UpdateExistingNotes.DeleteAndReAddNotes: + self.anki.delete_anki_cards(self.ImportProgress.GUIDs.Server.Existing.OutOfDate) + self.ImportProgress.processDeleteAndUpdateResults( + self.import_into_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) + show_report(" > Import Complete", self.ImportProgress.ResultsSummaryLines) + self.anki.stop_editing() + self.anki.collection().autosave() + + def proceed_autopage(self): + if not self.autoPagingEnabled: + return + global latestEDAMRateLimit, latestSocketError + col = self.anki.collection() + status = self.ImportProgress.Status + if status == EvernoteAPIStatus.RateLimitError: + m, s = divmod(latestEDAMRateLimit, 60) + show_report(" > Error: Delaying Auto Paging", + "Over the rate limit when getting Evernote notes<BR>Evernote requested we wait %d:%02d min" % ( + m, s), delay=5) + mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(True), False) + return False + if status == EvernoteAPIStatus.SocketError: + show_report(" > Error: Delaying Auto Paging:", + "%s when getting Evernote notes" % latestSocketError[ + 'friendly_error_msg'], + "We will try again in 30 seconds", delay=5) + mw.progress.timer(30000, lambda: self.proceed(True), False) + return False + if self.MetadataProgress.IsFinished: + self.currentPage = 1 + if self.forceAutoPage: + show_report(" > Terminating Auto Paging", + "All %d notes have been processed and forceAutoPage is True" % self.MetadataProgress.Total, + delay=5) + if self.auto_page_callback: + self.auto_page_callback() + return True + elif col.conf.get(EVERNOTE.PAGING_RESTART_WHEN_COMPLETE, True): + restart = max(EVERNOTE.PAGING_RESTART_INTERVAL, 60*15) + restart_title = " > Restarting Auto Paging" + restart_msg = "All %d notes have been processed and EVERNOTE.PAGING_RESTART_WHEN_COMPLETE is TRUE<BR>" % \ + self.MetadataProgress.Total + suffix = "Per EVERNOTE.PAGING_RESTART_INTERVAL, " + else: + show_report(" > Completed Auto Paging", + "All %d notes have been processed and EVERNOTE.PAGING_RESTART_WHEN_COMPLETE is FALSE" % + self.MetadataProgress.Total, delay=5) + return True + else: # Paging still in progress + self.currentPage = self.MetadataProgress.Page + 1 + restart_title = " > Continuing Auto Paging" + restart_msg = "Page %d completed<BR>%d notes remain<BR>%d of %d notes have been processed" % ( + self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.Completed, + self.MetadataProgress.Total) + restart = 0 + if self.forceAutoPage: + suffix = "<BR>Not delaying as the forceAutoPage flag is set" + elif self.ImportProgress.APICallCount < EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS: + suffix = "<BR>Not delaying as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS" % ( + self.ImportProgress.APICallCount, EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS) + else: + restart = max(EVERNOTE.PAGING_TIMER_INTERVAL, 60*10) + suffix = "<BR>Delaying Auto Paging: Per EVERNOTE.PAGING_TIMER_INTERVAL, " + + if not self.forceAutoPage: + col.conf[SETTINGS.EVERNOTE_PAGINATION_CURRENT_PAGE] = self.currentPage + col.setMod() + col.save() + + if restart > 0: + m, s = divmod(restart, 60) + suffix += "will delay for %d:%02d min before continuing\n" % (m, s) + show_report(restart_title, (restart_msg + suffix).split('<BR>'), delay=5) + if restart > 0: + mw.progress.timer(restart * 1000, lambda: self.proceed(True), False) + return False + return self.proceed(True) + + @property + def autoPagingEnabled(self): + return self.anki.collection().conf.get(SETTINGS.EVERNOTE_AUTO_PAGING, True) or self.forceAutoPage diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py new file mode 100644 index 0000000..3697011 --- /dev/null +++ b/anknotes/EvernoteNoteFetcher.py @@ -0,0 +1,174 @@ +### Python Imports +import socket + +### Anknotes Shared Imports +from anknotes.shared import * +from anknotes.EvernoteNotePrototype import EvernoteNotePrototype +from anknotes.error import * + +### Evernote Imports +from evernote.edam.error.ttypes import EDAMSystemException + + +class EvernoteNoteFetcher(object): + def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): + """ + + :type evernote: ankEvernote.Evernote + """ + self.__reset_data__() + self.results = EvernoteNoteFetcherResults() + self.result = EvernoteNoteFetcherResult() + self.api_calls = 0 + self.keepEvernoteTags = True + self.deleteQueryTags = True + self.evernoteQueryTags = [] + self.tagsToDelete = [] + self.use_local_db_only = use_local_db_only + self.__update_sequence_number__ = -1 + if evernote: self.evernote = evernote + if not evernote_guid: + self.evernote_guid = "" + return + self.evernote_guid = evernote_guid + if evernote and not self.use_local_db_only: + self.__update_sequence_number__ = self.evernote.metadata[self.evernote_guid].updateSequenceNum + self.getNote() + + def __reset_data__(self): + self.tagNames = [] + self.tagGuids = [] + self.whole_note = None + + def UpdateSequenceNum(self): + if self.result.Note: + return self.result.Note.UpdateSequenceNum + return self.__update_sequence_number__ + + def reportSuccess(self, note, source=None): + self.reportResult(EvernoteAPIStatus.Success, note, source) + + def reportResult(self, status=None, note=None, source=None): + if note: + self.result.Note = note + status = EvernoteAPIStatus.Success + if not source: + source = 2 + if status: + self.result.Status = status + if source: + self.result.Source = source + self.results.reportResult(self.result) + + def getNoteLocal(self): + # Check Anknotes database for note + query = "SELECT * FROM %s WHERE guid = '%s'" % ( + TABLES.EVERNOTE.NOTES, self.evernote_guid) + if self.UpdateSequenceNum() > -1: + query += " AND `updateSequenceNum` = %d" % self.UpdateSequenceNum() + db_note = ankDB().first(query) + """:type : sqlite.Row""" + if not db_note: return False + if not self.use_local_db_only: + log(" > getNoteLocal: GUID: '%s': %-40s" % (self.evernote_guid, db_note['title']), 'api') + assert db_note['guid'] == self.evernote_guid + self.reportSuccess(EvernoteNotePrototype(db_note=db_note), 1) + self.setNoteTags(tag_names=self.result.Note.TagNames) + return True + + def setNoteTags(self, tag_names=None, tag_guids=None): + if not self.keepEvernoteTags: + self.tagNames = [] + self.tagGuids = [] + return + if not tag_names: tag_names = self.tagNames if self.tagNames else self.result.Note.TagNames + if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else self.result.Note.TagGuids if self.result.Note else self.whole_note.tagGuids if self.whole_note else None + self.tagGuids, self.tagNames = self.evernote.get_matching_tag_data(tag_guids, tag_names) + + def addNoteFromServerToDB(self, whole_note=None, tag_names=None): + """ + Adds note to Anknote DB from an Evernote Note object provided by the Evernote API + :type whole_note : evernote.edam.type.ttypes.Note + """ + if whole_note: + self.whole_note = whole_note + if tag_names: + self.tagNames = tag_names + title = self.whole_note.title + log('Adding %s: %s' % (self.whole_note.guid, title), 'ankDB') + content = self.whole_note.content + tag_names = u',' + u','.join(self.tagNames).decode('utf-8') + u',' + if isinstance(title, str): + title = unicode(title, 'utf-8') + if isinstance(content, str): + content = unicode(content, 'utf-8') + if isinstance(tag_names, str): + tag_names = unicode(tag_names, 'utf-8') + title = title.replace(u'\'', u'\'\'') + content = content.replace(u'\'', u'\'\'') + tag_names = tag_names.replace(u'\'', u'\'\'') + if not self.tagGuids: + self.tagGuids = self.whole_note.tagGuids + sql_query_header = u'INSERT OR REPLACE INTO `%s`' % TABLES.EVERNOTE.NOTES + sql_query_header_history = u'INSERT INTO `%s`' % TABLES.EVERNOTE.NOTES_HISTORY + sql_query_columns = u'(`guid`,`title`,`content`,`updated`,`created`,`updateSequenceNum`,`notebookGuid`,`tagGuids`,`tagNames`) VALUES (\'%s\',\'%s\',\'%s\',%d,%d,%d,\'%s\',\'%s\',\'%s\');' % ( + self.whole_note.guid.decode('utf-8'), title, content, self.whole_note.updated, self.whole_note.created, + self.whole_note.updateSequenceNum, self.whole_note.notebookGuid.decode('utf-8'), + u',' + u','.join(self.tagGuids).decode('utf-8') + u',', tag_names) + sql_query = sql_query_header + sql_query_columns + log_sql('UPDATE_ANKI_DB: Add Note: SQL Query: ' + sql_query) + ankDB().execute(sql_query) + sql_query = sql_query_header_history + sql_query_columns + ankDB().execute(sql_query) + ankDB().commit() + + def getNoteRemoteAPICall(self): + api_action_str = u'trying to retrieve a note. We will save the notes downloaded thus far.' + log_api(" > getNote [%3d]" % (self.api_calls + 1), "GUID: '%s'" % self.evernote_guid) + + try: + self.whole_note = self.evernote.noteStore.getNote(self.evernote.token, self.evernote_guid, True, False, + False, False) + """:type : evernote.edam.type.ttypes.Note""" + except EDAMSystemException as e: + if HandleEDAMRateLimitError(e, api_action_str): + self.reportResult(EvernoteAPIStatus.RateLimitError) + if DEBUG_RAISE_API_ERRORS: raise + return False + raise + except socket.error, v: + if HandleSocketError(v, api_action_str): + self.reportResult(EvernoteAPIStatus.SocketError) + if DEBUG_RAISE_API_ERRORS: raise + return False + raise + assert self.whole_note.guid == self.evernote_guid + return True + + def getNoteRemote(self): + # if self.getNoteCount > EVERNOTE.GET_NOTE_LIMIT: + # log("Aborting Evernote.getNoteRemote: EVERNOTE.GET_NOTE_LIMIT of %d has been reached" % EVERNOTE.GET_NOTE_LIMIT) + # return None + if not self.getNoteRemoteAPICall(): return False + self.api_calls += 1 + # self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_evernote_guids(self.whole_note.tagGuids) + self.setNoteTags(tag_guids=self.whole_note.tagGuids) + self.addNoteFromServerToDB() + if not self.keepEvernoteTags: self.tagNames = [] + self.reportSuccess(EvernoteNotePrototype(whole_note=self.whole_note, tags=self.tagNames)) + return True + + def setNote(self, whole_note): + self.whole_note = whole_note + self.addNoteFromServerToDB() + + def getNote(self, evernote_guid=None): + self.__reset_data__() + if evernote_guid: + self.result.Note = None + self.evernote_guid = evernote_guid + self.__update_sequence_number__ = self.evernote.metadata[ + self.evernote_guid].updateSequenceNum if not self.use_local_db_only else -1 + if self.getNoteLocal(): return True + if self.use_local_db_only: return False + return self.getNoteRemote() diff --git a/anknotes/EvernoteNotePrototype.py b/anknotes/EvernoteNotePrototype.py new file mode 100644 index 0000000..22af90b --- /dev/null +++ b/anknotes/EvernoteNotePrototype.py @@ -0,0 +1,137 @@ +from anknotes.EvernoteNoteTitle import EvernoteNoteTitle +from anknotes.html import generate_evernote_url, generate_evernote_link, generate_evernote_link_by_level +from anknotes.structs import upperFirst, EvernoteAPIStatus +from anknotes.logging import log, log_blank, log_error + +class EvernoteNotePrototype: + ################## CLASS Note ################ + Title = None + """:type: EvernoteNoteTitle""" + Content = "" + Guid = "" + UpdateSequenceNum = -1 + """:type: int""" + TagNames = [] + TagGuids = [] + NotebookGuid = "" + Status = EvernoteAPIStatus.Uninitialized + """:type : EvernoteAPIStatus """ + Children = [] + + @property + def Tags(self): + return self.TagNames + + def process_tags(self): + if isinstance(self.TagNames, str) or isinstance(self.TagNames, unicode): + self.TagNames = self.TagNames[1:-1].split(',') + if isinstance(self.TagGuids, str) or isinstance(self.TagGuids, unicode): + self.TagGuids = self.TagGuids[1:-1].split(',') + + def __repr__(self): + return u"<EN Note: %s: '%s'>" % (self.Guid, self.Title) + + def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid=None, updateSequenceNum=None, + whole_note=None, db_note=None): + """ + + :type whole_note: evernote.edam.type.ttypes.Note + :type db_note: sqlite3.dbapi2.Row + """ + + self.Status = EvernoteAPIStatus.Uninitialized + self.TagNames = tags + if whole_note is not None: + self.Title = EvernoteNoteTitle(whole_note) + self.Content = whole_note.content + self.Guid = whole_note.guid + self.NotebookGuid = whole_note.notebookGuid + self.UpdateSequenceNum = whole_note.updateSequenceNum + self.Status = EvernoteAPIStatus.Success + return + if db_note is not None: + self.Title = EvernoteNoteTitle(db_note) + db_note_keys = db_note.keys() + if isinstance(db_note['tagNames'], str): + db_note['tagNames'] = unicode(db_note['tagNames'], 'utf-8') + for key in ['content', 'guid', 'notebookGuid', 'updateSequenceNum', 'tagNames', 'tagGuids']: + if not key in db_note_keys: + log_error("FATAL ERROR: Unable to find key %s in db note %s! \n%s" % (key, self.Title.FullTitle, db_note_keys)) + log("Values: \n\n" + str({k: db_note[k] for k in db_note_keys }), 'EvernoteNotePrototypeInit') + else: + setattr(self, upperFirst(key), db_note[key]) + if isinstance(self.Content, str): + self.Content = unicode(self.Content, 'utf-8') + self.process_tags() + self.Status = EvernoteAPIStatus.Success + return + self.Title = EvernoteNoteTitle(title) + self.Content = content + self.Guid = guid + self.NotebookGuid = notebookGuid + self.UpdateSequenceNum = updateSequenceNum + self.Status = EvernoteAPIStatus.Manual + + def generateURL(self): + return generate_evernote_url(self.Guid) + + def generateLink(self, value=None): + return generate_evernote_link(self.Guid, self.Title.Name, value) + + def generateLevelLink(self, value=None): + return generate_evernote_link_by_level(self.Guid, self.Title.Name, value) + + ### Shortcuts to EvernoteNoteTitle Properties; Autogenerated with regex /def +(\w+)\(\)\:/def \1\(\):\r\n\treturn self.Title.\1\r\n/ + @property + def Level(self): + return self.Title.Level + + @property + def Depth(self): + return self.Title.Depth + + @property + def FullTitle(self): + return self.Title.FullTitle + + @property + def Name(self): + return self.Title.Name + + @property + def Root(self): + return self.Title.Root + + @property + def Base(self): + return self.Title.Base + + @property + def Parent(self): + return self.Title.Parent + + @property + def TitleParts(self): + return self.Title.TitleParts + + @property + def IsChild(self): + return self.Title.IsChild + + @property + def IsRoot(self): + return self.Title.IsRoot + + @property + def IsAboveLevel(self, level_check): + return self.Title.IsAboveLevel(level_check) + + @property + def IsBelowLevel(self, level_check): + return self.Title.IsBelowLevel(level_check) + + @property + def IsLevel(self, level_check): + return self.Title.IsLevel(level_check) + + ################## END CLASS Note ################ diff --git a/anknotes/EvernoteNoteTitle.py b/anknotes/EvernoteNoteTitle.py new file mode 100644 index 0000000..63a1468 --- /dev/null +++ b/anknotes/EvernoteNoteTitle.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +### Anknotes Shared Imports +from anknotes.shared import * +from sys import stderr + + +def generateTOCTitle(title): + title = EvernoteNoteTitle.titleObjectToString(title).upper() + for chr in u'αβδφḃ': + title = title.replace(chr.upper(), chr) + return title + +class EvernoteNoteTitle: + level = 0 + __title__ = "" + """:type: str""" + __titleParts__ = None + """:type: list[str]""" + + # # Parent = None + # def __str__(self): + # return "%d: %s" % (self.Level(), self.Title) + + def __repr__(self): + return "<%s:%s>" % (self.__class__.__name__, self.FullTitle) + + @property + def TitleParts(self): + if not self.FullTitle: return [] + if not self.__titleParts__: self.__titleParts__ = generateTitleParts(self.FullTitle) + return self.__titleParts__ + + @property + def Level(self): + """ + :rtype: int + :return: Current Level with 1 being the Root Title + """ + if not self.level: self.level = len(self.TitleParts) + return self.level + + @property + def Depth(self): + return self.Level - 1 + + def Parts(self, level=-1): + return self.Slice(level) + + def Part(self, level=-1): + mySlice = self.Parts(level) + if not mySlice: return None + return mySlice.Root + + def BaseParts(self, level=None): + return self.Slice(1, level) + + def Parents(self, level=-1): + # noinspection PyTypeChecker + return self.Slice(None, level) + + def Names(self, level=-1): + return self.Parts(level) + + @property + def TOCTitle(self): + return generateTOCTitle(self.FullTitle) + + @property + def TOCName(self): + return generateTOCTitle(self.Name) + + @property + def TOCRootTitle(self): + return generateTOCTitle(self.Root) + + @property + def Name(self): + return self.Part() + + @property + def Root(self): + return self.Parents(1).FullTitle + + @property + def Base(self): + return self.BaseParts() + + def Slice(self, start=0, end=None): + # print "Slicing: <%s> %s ~ %d,%d" % (type(self.Title), self.Title, start, end) + oldParts = self.TitleParts + # print "Slicing: %s ~ %d,%d from parts %s" % (self.Title, start, end, str(oldParts)) + assert self.FullTitle and oldParts + if start is None and end is None: + print "Slicing: %s ~ %d,%d from parts %s" % (self.FullTitle, start, end, str(oldParts)) + assert start is not None or end is not None + newParts = oldParts[start:end] + if len(newParts) == 0: + log_error("Slice failed for %s-%s of %s" % (str(start), str(end), self.FullTitle)) + # return None + assert len(newParts) > 0 + newStr = ': '.join(newParts) + # print "Slice: Just created new title %s from %s" % (newStr , self.Title) + return EvernoteNoteTitle(newStr) + + @property + def Parent(self): + return self.Parents() + + def IsAboveLevel(self, level_check): + return self.Level > level_check + + def IsBelowLevel(self, level_check): + return self.Level < level_check + + def IsLevel(self, level_check): + return self.Level == level_check + + @property + def IsChild(self): + return self.IsAboveLevel(1) + + @property + def IsRoot(self): + return self.IsLevel(1) + + @staticmethod + def titleObjectToString(title, recursion=0): + """ + :param title: Title in string, unicode, dict, sqlite, TOCKey or NoteTitle formats. Note objects are also parseable + :type title: None | str | unicode | dict[str,str] | sqlite.Row | EvernoteNoteTitle + :return: string Title + :rtype: str + """ + # if recursion == 0: + # strr = str_safe(title) + # try: log(u'\n---------------------------------%s' % strr, 'tOTS', timestamp=False) + # except: log(u'\n---------------------------------%s' % '[UNABLE TO DISPLAY TITLE]', 'tOTS', timestamp=False) + # pass + + if title is None: + # log('NoneType', 'tOTS', timestamp=False) + return "" + if isinstance(title, str) or isinstance(title, unicode): + # log('str/unicode', 'tOTS', timestamp=False) + return title + if hasattr(title, 'FullTitle'): + # log('FullTitle', 'tOTS', timestamp=False) + title = title.FullTitle() if callable(title.FullTitle) else title.FullTitle + elif hasattr(title, 'Title'): + # log('Title', 'tOTS', timestamp=False) + title = title.Title() if callable(title.Title) else title.Title + elif hasattr(title, 'title'): + # log('title', 'tOTS', timestamp=False) + title = title.title() if callable(title.title) else title.title + else: + try: + if hasattr(title, 'keys'): + keys = title.keys() if callable(title.keys) else title.keys + if 'title' in keys: + # log('keys[title]', 'tOTS', timestamp=False) + title = title['title'] + elif 'Title' in keys: + # log('keys[Title]', 'tOTS', timestamp=False) + title = title['Title'] + elif len(keys) == 0: + # log('keys[empty dict?]', 'tOTS', timestamp=False) + raise + else: + # log('keys[Unknown Attr]: %s' % str(keys), 'tOTS', timestamp=False) + return "" + elif 'title' in title: + # log('[title]', 'tOTS', timestamp=False) + title = title['title'] + elif 'Title' in title: + # log('[Title]', 'tOTS', timestamp=False) + title = title['Title'] + elif FIELDS.TITLE in title: + # log('[FIELDS.TITLE]', 'tOTS', timestamp=False) + title = title[FIELDS.TITLE] + else: + # log('Nothing Found', 'tOTS', timestamp=False) + # log(title) + # log(title.keys()) + return title + except: + log('except', 'tOTS', timestamp=False) + log(title, 'toTS', timestamp=False) + raise LookupError + recursion += 1 + # log(u'recursing %d: ' % recursion, 'tOTS', timestamp=False) + return EvernoteNoteTitle.titleObjectToString(title, recursion) + + @property + def FullTitle(self): + """:rtype: str""" + return self.__title__ + + @property + def HTML(self): + return self.__html__ + + def __init__(self, titleObj=None): + """:type titleObj: str | unicode | sqlite.Row | EvernoteNoteTitle | evernote.edam.type.ttypes.Note | EvernoteNotePrototype.EvernoteNotePrototype """ + self.__html__ = self.titleObjectToString(titleObj) + self.__title__ = strip_tags_and_new_lines(self.__html__) + + +def generateTitleParts(title): + title = EvernoteNoteTitle.titleObjectToString(title) + try: + strTitle = re.sub(':+', ':', title) + except: + log('generateTitleParts Unable to re.sub') + log(type(title)) + raise + if strTitle[-1] == ':': strTitle = strTitle[:-1] + if strTitle[0] == ':': strTitle = strTitle[1:] + partsText = strTitle.split(':') + count = len(partsText) + for i in range(1, count + 1): + txt = partsText[i - 1] + try: + if txt[-1] == ' ': txt = txt[:-1] + if txt[0] == ' ': txt = txt[1:] + except: + print_safe(title + ' -- ' + '"' + txt + '"') + raise + partsText[i - 1] = txt + return partsText diff --git a/anknotes/EvernoteNotes.py b/anknotes/EvernoteNotes.py new file mode 100644 index 0000000..c824e40 --- /dev/null +++ b/anknotes/EvernoteNotes.py @@ -0,0 +1,429 @@ +# -*- coding: utf-8 -*- +### Python Imports +from operator import itemgetter + +from anknotes.EvernoteNoteTitle import generateTOCTitle + +try: + from pysqlite2 import dbapi2 as sqlite +except ImportError: + from sqlite3 import dbapi2 as sqlite + +### Anknotes Imports +from anknotes.shared import * +from anknotes.EvernoteNoteTitle import * +from anknotes.EvernoteNotePrototype import EvernoteNotePrototype +from anknotes.toc import TOCHierarchyClass +from anknotes.db import ankDB + + +class EvernoteNoteProcessingFlags: + delayProcessing = False + populateRootTitlesList = True + populateRootTitlesDict = True + populateExistingRootTitlesList = False + populateExistingRootTitlesDict = False + populateMissingRootTitlesList = False + populateMissingRootTitlesDict = False + populateChildRootTitles = False + ignoreAutoTOCAsRootTitle = False + ignoreOutlineAsRootTitle = False + + def __init__(self, flags=None): + if isinstance(flags, bool): + if not flags: self.set_default(False) + if flags: self.update(flags) + + def set_default(self, flag): + self.populateRootTitlesList = flag + self.populateRootTitlesDict = flag + + def update(self, flags): + for flag_name, flag_value in flags: + if hasattr(self, flag_name): + setattr(self, flag_name, flag_value) + + +class EvernoteNotesCollection: + TitlesList = [] + TitlesDict = {} + NotesDict = {} + """:type : dict[str, EvernoteNote.EvernoteNote]""" + ChildNotesDict = {} + """:type : dict[str, EvernoteNote.EvernoteNote]""" + ChildTitlesDict = {} + + def __init__(self): + self.TitlesList = [] + self.TitlesDict = {} + self.NotesDict = {} + self.ChildNotesDict = {} + self.ChildTitlesDict = {} + + +class EvernoteNotes: + ################## CLASS Notes ################ + Notes = {} + """:type : dict[str, EvernoteNote.EvernoteNote]""" + RootNotes = EvernoteNotesCollection() + RootNotesChildren = EvernoteNotesCollection() + processingFlags = EvernoteNoteProcessingFlags() + baseQuery = "1" + + def __init__(self, delayProcessing=False): + self.processingFlags.delayProcessing = delayProcessing + self.RootNotes = EvernoteNotesCollection() + + def addNoteSilently(self, enNote): + """:type enNote: EvernoteNote.EvernoteNote""" + assert enNote + self.Notes[enNote.Guid] = enNote + + def addNote(self, enNote): + """:type enNote: EvernoteNote.EvernoteNote""" + assert enNote + self.addNoteSilently(enNote) + if self.processingFlags.delayProcessing: return + self.processNote(enNote) + + def addDBNote(self, dbNote): + """:type dbNote: sqlite.Row""" + enNote = EvernoteNotePrototype(db_note=dbNote) + if not enNote: + log(dbNote) + log(dbNote.keys) + log(dir(dbNote)) + assert enNote + self.addNote(enNote) + + def addDBNotes(self, dbNotes): + """:type dbNotes: list[sqlite.Row]""" + for dbNote in dbNotes: + self.addDBNote(dbNote) + + def addDbQuery(self, sql_query, order=''): + sql_query = "SELECT * FROM %s WHERE (%s) AND (%s) " % (TABLES.EVERNOTE.NOTES, self.baseQuery, sql_query) + if order: sql_query += ' ORDER BY ' + order + dbNotes = ankDB().execute(sql_query) + self.addDBNotes(dbNotes) + + @staticmethod + def getNoteFromDB(query): + """ + + :param query: + :return: + :rtype : sqlite.Row + """ + sql_query = "SELECT * FROM %s WHERE %s " % (TABLES.EVERNOTE.NOTES, query) + dbNote = ankDB().first(sql_query) + if not dbNote: return None + return dbNote + + def getNoteFromDBByGuid(self, guid): + sql_query = "guid = '%s' " % guid + return self.getNoteFromDB(sql_query) + + def getEnNoteFromDBByGuid(self, guid): + return EvernoteNotePrototype(db_note=self.getNoteFromDBByGuid(guid)) + + + # def addChildNoteHierarchically(self, enChildNotes, enChildNote): + # parts = enChildNote.Title.TitleParts + # dict_updated = {} + # dict_building = {parts[len(parts)-1]: enChildNote} + # print_safe(parts) + # for i in range(len(parts), 1, -1): + # dict_building = {parts[i - 1]: dict_building} + # log_dump(dict_building) + # enChildNotes.update(dict_building) + # log_dump(enChildNotes) + # return enChildNotes + + def processNote(self, enNote): + """:type enNote: EvernoteNote.EvernoteNote""" + if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict or self.processingFlags.populateMissingRootTitlesList or self.processingFlags.populateMissingRootTitlesDict: + if enNote.IsChild: + # log([enNote.Title, enNote.Level, enNote.Title.TitleParts, enNote.IsChild]) + rootTitle = enNote.Title.Root + rootTitleStr = generateTOCTitle(rootTitle) + if self.processingFlags.populateMissingRootTitlesList or self.processingFlags.populateMissingRootTitlesDict: + if not rootTitleStr in self.RootNotesExisting.TitlesList: + if not rootTitleStr in self.RootNotesMissing.TitlesList: + self.RootNotesMissing.TitlesList.append(rootTitleStr) + self.RootNotesMissing.ChildTitlesDict[rootTitleStr] = {} + self.RootNotesMissing.ChildNotesDict[rootTitleStr] = {} + if not enNote.Title.Base: + log(enNote.Title) + log(enNote.Base) + assert enNote.Title.Base + childBaseTitleStr = enNote.Title.Base.FullTitle + if childBaseTitleStr in self.RootNotesMissing.ChildTitlesDict[rootTitleStr]: + log_dump(self.RootNotesMissing.ChildTitlesDict[rootTitleStr], repr(enNote)) + assert not childBaseTitleStr in self.RootNotesMissing.ChildTitlesDict[rootTitleStr] + self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitleStr] = enNote.Guid + self.RootNotesMissing.ChildNotesDict[rootTitleStr][enNote.Guid] = enNote + if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: + if not rootTitleStr in self.RootNotes.TitlesList: + self.RootNotes.TitlesList.append(rootTitleStr) + if self.processingFlags.populateRootTitlesDict: + self.RootNotes.TitlesDict[rootTitleStr][enNote.Guid] = enNote.Title.Base + self.RootNotes.NotesDict[rootTitleStr][enNote.Guid] = enNote + if self.processingFlags.populateChildRootTitles or self.processingFlags.populateExistingRootTitlesList or self.processingFlags.populateExistingRootTitlesDict: + if enNote.IsRoot: + rootTitle = enNote.Title + rootTitleStr = generateTOCTitle(rootTitle) + rootGuid = enNote.Guid + if self.processingFlags.populateExistingRootTitlesList or self.processingFlags.populateExistingRootTitlesDict or self.processingFlags.populateMissingRootTitlesList: + if not rootTitleStr in self.RootNotesExisting.TitlesList: + self.RootNotesExisting.TitlesList.append(rootTitleStr) + if self.processingFlags.populateChildRootTitles: + childNotes = ankDB().execute("SELECT * FROM %s WHERE title LIKE '%s:%%' ORDER BY title ASC" % ( + TABLES.EVERNOTE.NOTES, rootTitleStr.replace("'", "''"))) + child_count = 0 + for childDbNote in childNotes: + child_count += 1 + childGuid = childDbNote['guid'] + childEnNote = EvernoteNotePrototype(db_note=childDbNote) + if child_count is 1: + self.RootNotesChildren.TitlesDict[rootGuid] = {} + self.RootNotesChildren.NotesDict[rootGuid] = {} + childBaseTitle = childEnNote.Title.Base + self.RootNotesChildren.TitlesDict[rootGuid][childGuid] = childBaseTitle + self.RootNotesChildren.NotesDict[rootGuid][childGuid] = childEnNote + + def processNotes(self, populateRootTitlesList=True, populateRootTitlesDict=True): + if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: + self.RootNotes = EvernoteNotesCollection() + + self.processingFlags.populateRootTitlesList = populateRootTitlesList + self.processingFlags.populateRootTitlesDict = populateRootTitlesDict + + for guid, enNote in self.Notes: + self.processNote(enNote) + + def processAllChildNotes(self): + self.processingFlags.populateRootTitlesList = True + self.processingFlags.populateRootTitlesDict = True + self.processNotes() + + def populateAllRootTitles(self): + self.getChildNotes() + self.processAllRootTitles() + + def processAllRootTitles(self): + count = 0 + for rootTitle, baseTitles in self.RootNotes.TitlesDict.items(): + count += 1 + baseNoteCount = len(baseTitles) + query = "UPPER(title) = '%s'" % escape_text_sql(rootTitle).upper() + if self.processingFlags.ignoreAutoTOCAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % EVERNOTE.TAG.AUTO_TOC + if self.processingFlags.ignoreOutlineAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % EVERNOTE.TAG.OUTLINE + rootNote = self.getNoteFromDB(query) + if rootNote: + self.RootNotesExisting.TitlesList.append(rootTitle) + else: + self.RootNotesMissing.TitlesList.append(rootTitle) + print_safe(rootNote, ' TOP LEVEL: [%4d::%2d]: [%7s] ' % (count, baseNoteCount, 'is_toc_outline_str')) + # for baseGuid, baseTitle in baseTitles: + # pass + + def getChildNotes(self): + self.addDbQuery("title LIKE '%%:%%'", 'title ASC') + + def getRootNotes(self): + query = "title NOT LIKE '%%:%%'" + if self.processingFlags.ignoreAutoTOCAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % EVERNOTE.TAG.AUTO_TOC + if self.processingFlags.ignoreOutlineAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % EVERNOTE.TAG.OUTLINE + self.addDbQuery(query, 'title ASC') + + def populateAllPotentialRootNotes(self): + self.RootNotesMissing = EvernoteNotesCollection() + processingFlags = EvernoteNoteProcessingFlags(False) + processingFlags.populateMissingRootTitlesList = True + processingFlags.populateMissingRootTitlesDict = True + self.processingFlags = processingFlags + + log(" CHECKING FOR ALL POTENTIAL ROOT TITLES ", 'RootTitles-TOC', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-TOC', timestamp=False) + log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) + self.getChildNotes() + log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-TOC', + timestamp=False) + self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) + + return self.processAllRootNotesMissing() + + def populateAllNonCustomRootNotes(self): + return self.populateAllRootNotesMissing(True, True) + + def populateAllRootNotesMissing(self, ignoreAutoTOCAsRootTitle=False, ignoreOutlineAsRootTitle=False): + processingFlags = EvernoteNoteProcessingFlags(False) + processingFlags.populateMissingRootTitlesList = True + processingFlags.populateMissingRootTitlesDict = True + processingFlags.populateExistingRootTitlesList = True + processingFlags.populateExistingRootTitlesDict = True + processingFlags.ignoreAutoTOCAsRootTitle = ignoreAutoTOCAsRootTitle + processingFlags.ignoreOutlineAsRootTitle = ignoreOutlineAsRootTitle + self.processingFlags = processingFlags + self.RootNotesExisting = EvernoteNotesCollection() + self.RootNotesMissing = EvernoteNotesCollection() + # log(', '.join(self.RootNotesMissing.TitlesList)) + self.getRootNotes() + + log(" CHECKING FOR MISSING ROOT TITLES ", 'RootTitles-Missing', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-Missing', timestamp=False) + log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) + log("Total %d Existing Root Titles" % len(self.RootNotesExisting.TitlesList), 'RootTitles-Missing', + timestamp=False) + self.getChildNotes() + log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-Missing', + timestamp=False) + self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) + + return self.processAllRootNotesMissing() + + def processAllRootNotesMissing(self): + """:rtype : list[EvernoteTOCEntry]""" + DEBUG_HTML = False + count = 0 + count_isolated = 0 + # log (" CREATING TOC's " , 'tocList', clear=True, timestamp=False) + # log ("------------------------------------------------" , 'tocList', timestamp=False) + # if DEBUG_HTML: log('<h1>CREATING TOCs</h1>', 'extra\\logs\\toc-ols\\toc-index.htm', timestamp=False, clear=True, extension='htm') + ols = [] + dbRows = [] + returns = [] + """:type : list[EvernoteTOCEntry]""" + ankDB().execute("DELETE FROM %s WHERE 1 " % TABLES.EVERNOTE.AUTO_TOC) + # olsz = None + for rootTitleStr in self.RootNotesMissing.TitlesList: + count_child = 0 + childTitlesDictSortedKeys = sorted(self.RootNotesMissing.ChildTitlesDict[rootTitleStr], + key=lambda s: s.lower()) + total_child = len(childTitlesDictSortedKeys) + tags = [] + outline = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( + escape_text_sql(rootTitleStr.upper()), EVERNOTE.TAG.OUTLINE)) + currentAutoNote = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( + escape_text_sql(rootTitleStr.upper()), EVERNOTE.TAG.AUTO_TOC)) + notebookGuids = {} + childGuid = None + if total_child is 1 and not outline: + count_isolated += 1 + childBaseTitle = childTitlesDictSortedKeys[0] + childGuid = self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitle] + enChildNote = self.RootNotesMissing.ChildNotesDict[rootTitleStr][childGuid] + # tags = enChildNote.Tags + log(" > ISOLATED ROOT TITLE: [%-3d]: %-40s --> %-20s: %s %s" % ( + count_isolated, rootTitleStr + ':', childBaseTitle, childGuid, enChildNote), 'RootTitles-Isolated', + timestamp=False) + else: + count += 1 + log(" [%-3d] %s %s" % (count, rootTitleStr, '(O)' if outline else ' '), 'RootTitles-TOC', + timestamp=False) + # tocList = TOCList(rootTitleStr) + tocHierarchy = TOCHierarchyClass(rootTitleStr) + if outline: + tocHierarchy.Outline = TOCHierarchyClass(note=outline) + tocHierarchy.Outline.parent = tocHierarchy + + for childBaseTitle in childTitlesDictSortedKeys: + count_child += 1 + childGuid = self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitle] + enChildNote = self.RootNotesMissing.ChildNotesDict[rootTitleStr][childGuid] + if count_child == 1: + tags = enChildNote.Tags + else: + tags = [x for x in tags if x in enChildNote.Tags] + if not enChildNote.NotebookGuid in notebookGuids: + notebookGuids[enChildNote.NotebookGuid] = 0 + notebookGuids[enChildNote.NotebookGuid] += 1 + level = enChildNote.Title.Level + # childName = enChildNote.Title.Name + # childTitle = enChildNote.Title.FullTitle + log(" %2d: %d. --> %-60s" % (count_child, level, childBaseTitle), + 'RootTitles-TOC', timestamp=False) + # tocList.generateEntry(childTitle, enChildNote) + tocHierarchy.addNote(enChildNote) + realTitle = ankDB().scalar( + "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, childGuid)) + realTitle = realTitle[0:realTitle.index(':')] + # realTitleUTF8 = realTitle.encode('utf8') + notebookGuid = sorted(notebookGuids.items(), key=itemgetter(1), reverse=True)[0][0] + # if rootTitleStr.find('Antitrypsin') > -1: + # realTitleUTF8 = realTitle.encode('utf8') + # file_object = open('pytho2!nx_intro.txt', 'w') + # file_object.write(realTitleUTF8) + # file_object.close() + + ol = tocHierarchy.GetOrderedList() + tocEntry = EvernoteTOCEntry(realTitle, ol, ',' + ','.join(tags) + ',', notebookGuid) + returns.append(tocEntry) + dbRows.append(tocEntry.items()) + # ol = realTitleUTF8 + # if olsz is None: olsz = ol + # olsz += ol + # ol = '<OL>\r\n%s</OL>\r\n' + + # strr = tocHierarchy.__str__() + if DEBUG_HTML: + ols.append(ol) + olutf8 = ol.encode('utf8') + fn = 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_') + '.htm' + full_path = os.path.join(ANKNOTES.FOLDER_LOGS, fn) + if not os.path.exists(os.path.dirname(full_path)): + os.mkdir(os.path.dirname(full_path)) + file_object = open(full_path, 'w') + file_object.write(olutf8) + file_object.close() + + # if DEBUG_HTML: log(ol, 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_'), timestamp=False, clear=True, extension='htm') + # log("Created TOC #%d:\n%s\n\n" % (count, strr), 'tocList', timestamp=False) + if DEBUG_HTML: + ols_html = u'\r\n<BR><BR><HR><BR><BR>\r\n'.join(ols) + fn = 'toc-ols\\toc-index.htm' + file_object = open(os.path.join(ANKNOTES.FOLDER_LOGS, fn), 'w') + try: file_object.write(u'<h1>CREATING TOCs</h1>\n\n' + ols_html) + except: + try: file_object.write(u'<h1>CREATING TOCs</h1>\n\n' + ols_html.encode('utf-8')) + except: pass + + file_object.close() + + # print dbRows + ankDB().executemany( + "INSERT INTO %s (root_title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?)" % TABLES.EVERNOTE.AUTO_TOC, + dbRows) + ankDB().commit() + + return returns + + def populateAllRootNotesWithoutTOCOrOutlineDesignation(self): + processingFlags = EvernoteNoteProcessingFlags() + processingFlags.populateRootTitlesList = False + processingFlags.populateRootTitlesDict = False + processingFlags.populateChildRootTitles = True + self.processingFlags = processingFlags + self.getRootNotes() + self.processAllRootNotesWithoutTOCOrOutlineDesignation() + + def processAllRootNotesWithoutTOCOrOutlineDesignation(self): + count = 0 + for rootGuid, childBaseTitleDicts in self.RootNotesChildren.TitlesDict.items(): + rootEnNote = self.Notes[rootGuid] + if len(childBaseTitleDicts.items()) > 0: + is_toc = EVERNOTE.TAG.TOC in rootEnNote.Tags + is_outline = EVERNOTE.TAG.OUTLINE in rootEnNote.Tags + is_both = is_toc and is_outline + is_none = not is_toc and not is_outline + is_toc_outline_str = "BOTH ???" if is_both else "TOC" if is_toc else "OUTLINE" if is_outline else "N/A" + if is_none: + count += 1 + print_safe(rootEnNote, ' TOP LEVEL: [%3d] %-8s: ' % (count, is_toc_outline_str)) diff --git a/anknotes/__main__.py b/anknotes/__main__.py index f594b40..8f08ab9 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -1,393 +1,111 @@ -import os +# -*- coding: utf-8 -*- +### Python Imports +try: + from pysqlite2 import dbapi2 as sqlite +except ImportError: + from sqlite3 import dbapi2 as sqlite -# from thrift.Thrift import * -from evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec -from evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode -from evernote.api.client import EvernoteClient -# from evernote.edam.type.ttypes import SavedSearch +### Anknotes Shared Imports +from anknotes.shared import * -import anki -import aqt -from anki.hooks import wrap -from aqt.preferences import Preferences -from aqt.utils import showInfo, getText, openLink, getOnlyText -from aqt.qt import QLineEdit, QLabel, QVBoxLayout, QGroupBox, SIGNAL, QCheckBox, QComboBox, QSpacerItem, QSizePolicy, QWidget -from aqt import mw -# from pprint import pprint - - -# Note: This class was adapted from the Real-Time_Import_for_use_with_the_Rikaisama_Firefox_Extension plug-in -# by cb4960@gmail.com -# .. itself adapted from Yomichan plugin by Alex Yatskov. - -PATH = os.path.dirname(os.path.abspath(__file__)) -EVERNOTE_MODEL = 'evernote_note' -EVERNOTE_TEMPLATE_NAME = 'EvernoteReview' -TITLE_FIELD_NAME = 'title' -CONTENT_FIELD_NAME = 'content' -GUID_FIELD_NAME = 'Evernote GUID' - -SETTING_UPDATE_EXISTING_NOTES = 'evernoteUpdateExistingNotes' -SETTING_TOKEN = 'evernoteToken' -SETTING_KEEP_TAGS = 'evernoteKeepTags' -SETTING_TAGS_TO_IMPORT = 'evernoteTagsToImport' -SETTING_DEFAULT_TAG = 'evernoteDefaultTag' -SETTING_DEFAULT_DECK = 'evernoteDefaultDeck' - -class UpdateExistingNotes: - IgnoreExistingNotes, UpdateNotesInPlace, DeleteAndReAddNotes = range(3) - -class Anki: - def update_evernote_cards(self, evernote_cards, tag): - return self.add_evernote_cards(evernote_cards, None, tag, True) - - def add_evernote_cards(self, evernote_cards, deck, tag, update=False): - count = 0 - model_name = EVERNOTE_MODEL - for card in evernote_cards: - anki_field_info = {TITLE_FIELD_NAME: card.front.decode('utf-8'), - CONTENT_FIELD_NAME: card.back.decode('utf-8'), - GUID_FIELD_NAME: card.guid} - card.tags.append(tag) - if update: - self.update_note(anki_field_info, card.tags) - else: - self.add_note(deck, model_name, anki_field_info, card.tags) - count += 1 - return count - - def delete_anki_cards(self, guid_ids): - col = self.collection() - card_ids = [] - for guid in guid_ids: - card_ids += mw.col.findCards(guid) - col.remCards(card_ids) - return len(card_ids) - - def update_note(self, fields, tags=list()): - col = self.collection() - note_id = col.findNotes(fields[GUID_FIELD_NAME])[0] - note = anki.notes.Note(col, None, note_id) - note.tags = tags - for fld in note._model['flds']: - if TITLE_FIELD_NAME in fld.get('name'): - note.fields[fld.get('ord')] = fields[TITLE_FIELD_NAME] - elif CONTENT_FIELD_NAME in fld.get('name'): - note.fields[fld.get('ord')] = fields[CONTENT_FIELD_NAME] - # we dont have to update the evernote guid because if it changes we wont find this note anyway - note.flush() - return note.id - - def add_note(self, deck_name, model_name, fields, tags=list()): - note = self.create_note(deck_name, model_name, fields, tags) - if note is not None: - collection = self.collection() - collection.addNote(note) - collection.autosave() - self.start_editing() - return note.id - - def create_note(self, deck_name, model_name, fields, tags=list()): - id_deck = self.decks().id(deck_name) - model = self.models().byName(model_name) - col = self.collection() - note = anki.notes.Note(col, model) - note.model()['did'] = id_deck - note.tags = tags - for name, value in fields.items(): - note[name] = value - return note - - def add_evernote_model(self): # adapted from the IREAD plug-in from Frank - col = self.collection() - mm = col.models - evernote_model = mm.byName(EVERNOTE_MODEL) - if evernote_model is None: - evernote_model = mm.new(EVERNOTE_MODEL) - # Field for title: - model_field = mm.newField(TITLE_FIELD_NAME) - mm.addField(evernote_model, model_field) - # Field for text: - text_field = mm.newField(CONTENT_FIELD_NAME) - mm.addField(evernote_model, text_field) - # Field for source: - guid_field = mm.newField(GUID_FIELD_NAME) - guid_field['sticky'] = True - mm.addField(evernote_model, guid_field) - # Add template - t = mm.newTemplate(EVERNOTE_TEMPLATE_NAME) - t['qfmt'] = "{{" + TITLE_FIELD_NAME + "}}" - t['afmt'] = "{{" + CONTENT_FIELD_NAME + "}}" - mm.addTemplate(evernote_model, t) - mm.add(evernote_model) - return evernote_model - else: - fmap = mm.fieldMap(evernote_model) - title_ord, title_field = fmap[TITLE_FIELD_NAME] - text_ord, text_field = fmap[CONTENT_FIELD_NAME] - source_ord, source_field = fmap[GUID_FIELD_NAME] - source_field['sticky'] = False - - def get_guids_from_anki_id(self, ids): - guids = [] - for a_id in ids: - card = self.collection().getCard(a_id) - items = card.note().items() - if len(items) == 3: - guids.append(items[2][1]) # not a very smart access - return guids - - def can_add_note(self, deck_name, model_name, fields): - return bool(self.create_note(deck_name, model_name, fields)) - - def get_cards_id_from_tag(self, tag): - query = "tag:" + tag - ids = self.collection().findCards(query) - return ids - - def start_editing(self): - self.window().requireReset() - - def stop_editing(self): - if self.collection(): - self.window().maybeReset() - - def window(self): - return aqt.mw - - def collection(self): - return self.window().col - - def models(self): - return self.collection().models - - def decks(self): - return self.collection().decks - - -class EvernoteCard: - front = "" - back = "" - guid = "" - - def __init__(self, q, a, g, tags): - self.front = q - self.back = a - self.guid = g - self.tags = tags - - -class Evernote: - def __init__(self): - if not mw.col.conf.get(SETTING_TOKEN, False): - # First run of the Plugin we did not save the access key yet - client = EvernoteClient( - consumer_key='scriptkiddi-2682', - consumer_secret='965f1873e4df583c', - sandbox=False - ) - request_token = client.get_request_token('https://fap-studios.de/anknotes/index.html') - url = client.get_authorize_url(request_token) - showInfo("We will open a Evernote Tab in your browser so you can allow access to your account") - openLink(url) - oauth_verifier = getText(prompt="Please copy the code that showed up, after allowing access, in here")[0] - auth_token = client.get_access_token( - request_token.get('oauth_token'), - request_token.get('oauth_token_secret'), - oauth_verifier) - mw.col.conf[SETTING_TOKEN] = auth_token - else: - auth_token = mw.col.conf.get(SETTING_TOKEN, False) - self.token = auth_token - self.client = EvernoteClient(token=auth_token, sandbox=False) - self.noteStore = self.client.get_note_store() - - def find_tag_guid(self, tag): - list_tags = self.noteStore.listTags() - for evernote_tag in list_tags: - if str(evernote_tag.name).strip() == str(tag).strip(): - return evernote_tag.guid - - def create_evernote_cards(self, guid_set): - cards = [] - for guid in guid_set: - note_info = self.get_note_information(guid) - if note_info is None: - return cards - title, content, tags = note_info - cards.append(EvernoteCard(title, content, guid, tags)) - return cards +### Anknotes Main Imports +from anknotes import menu, settings - def find_notes_filter_by_tag_guids(self, guids_list): - evernote_filter = NoteFilter() - evernote_filter.ascending = False - evernote_filter.tagGuids = guids_list - spec = NotesMetadataResultSpec() - spec.includeTitle = True - note_list = self.noteStore.findNotesMetadata(self.token, evernote_filter, 0, 10000, spec) - guids = [] - for note in note_list.notes: - guids.append(note.guid) - return guids +### Evernote Imports - def get_note_information(self, note_guid): - tags = [] - try: - whole_note = self.noteStore.getNote(self.token, note_guid, True, True, False, False) - if mw.col.conf.get(SETTING_KEEP_TAGS, False): - tags = self.noteStore.getNoteTagNames(self.token, note_guid) - except EDAMSystemException, e: - if e.errorCode == EDAMErrorCode.RATE_LIMIT_REACHED: - m, s = divmod(e.rateLimitDuration, 60) - showInfo("Rate limit has been reached. We will save the notes downloaded thus far.\r\n" - "Please retry your request in {} min".format("%d:%02d" % (m, s))) - return None - raise - return whole_note.title, whole_note.content, tags - - -class Controller: - def __init__(self): - self.evernoteTags = mw.col.conf.get(SETTING_TAGS_TO_IMPORT, "").split(",") - self.ankiTag = mw.col.conf.get(SETTING_DEFAULT_TAG, "anknotes") - self.deck = mw.col.conf.get(SETTING_DEFAULT_DECK, "Default") - self.updateExistingNotes = mw.col.conf.get(SETTING_UPDATE_EXISTING_NOTES, - UpdateExistingNotes.UpdateNotesInPlace) - self.anki = Anki() - self.anki.add_evernote_model() - self.evernote = Evernote() - - def proceed(self): - anki_ids = self.anki.get_cards_id_from_tag(self.ankiTag) - anki_guids = self.anki.get_guids_from_anki_id(anki_ids) - evernote_guids = self.get_evernote_guids_from_tag(self.evernoteTags) - cards_to_add = set(evernote_guids) - set(anki_guids) - cards_to_update = set(evernote_guids) - set(cards_to_add) - self.anki.start_editing() - n = self.import_into_anki(cards_to_add, self.deck, self.ankiTag) - if self.updateExistingNotes is UpdateExistingNotes.IgnoreExistingNotes: - show_tooltip("{} new card(s) have been imported. Updating is disabled.".format(str(n))) +### Anki Imports +from anki.hooks import wrap, addHook +from aqt.preferences import Preferences +from aqt import mw, browser +# from aqt.qt import QIcon, QTreeWidget, QTreeWidgetItem +from aqt.qt import Qt, QIcon, QTreeWidget, QTreeWidgetItem +# from aqt.qt.Qt import MatchFlag +# from aqt.qt.qt import MatchFlag + +def import_timer_toggle(): + title = "&Enable Auto Import On Profile Load" + doAutoImport = mw.col.conf.get( + SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False) + if doAutoImport: + lastImport = mw.col.conf.get(SETTINGS.EVERNOTE_LAST_IMPORT, None) + importDelay = 0 + if lastImport: + td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) + minimum = timedelta(seconds=max(EVERNOTE.IMPORT_TIMER_INTERVAL, 20*60)) + if td < minimum: + importDelay = (minimum - td).total_seconds() * 1000 + if importDelay is 0: + menu.import_from_evernote() else: - n2 = len(cards_to_update) - if self.updateExistingNotes is UpdateExistingNotes.UpdateNotesInPlace: - update_str = "in-place" - self.update_in_anki(cards_to_update, self.ankiTag) - else: - update_str = "(deleted and re-added)" - self.anki.delete_anki_cards(cards_to_update) - self.import_into_anki(cards_to_update, self.deck, self.ankiTag) - show_tooltip("{} new card(s) have been imported and {} existing card(s) have been updated {}." - .format(str(n), str(n2), update_str)) - self.anki.stop_editing() - self.anki.collection().autosave() - - def update_in_anki(self, guid_set, tag): - cards = self.evernote.create_evernote_cards(guid_set) - number = self.anki.update_evernote_cards(cards, tag) - return number - - def import_into_anki(self, guid_set, deck, tag): - cards = self.evernote.create_evernote_cards(guid_set) - number = self.anki.add_evernote_cards(cards, deck, tag) - return number - - def get_evernote_guids_from_tag(self, tags): - note_guids = [] - for tag in tags: - tag_guid = self.evernote.find_tag_guid(tag) - if tag_guid is not None: - note_guids += self.evernote.find_notes_filter_by_tag_guids([tag_guid]) - return note_guids - - -def show_tooltip(text, time_out=3000): - aqt.utils.tooltip(text, time_out) - - -def main(): - controller = Controller() - controller.proceed() - - -action = aqt.qt.QAction("Import from Evernote", aqt.mw) -aqt.mw.connect(action, aqt.qt.SIGNAL("triggered()"), main) -aqt.mw.form.menuTools.addAction(action) - - -def setup_evernote(self): - global evernote_default_deck - global evernote_default_tag - global evernote_tags_to_import - global keep_evernote_tags - global update_existing_notes - - widget = QWidget() - layout = QVBoxLayout() - - # Default Deck - evernote_default_deck_label = QLabel("Default Deck:") - evernote_default_deck = QLineEdit() - evernote_default_deck.setText(mw.col.conf.get(SETTING_DEFAULT_DECK, "")) - layout.insertWidget(int(layout.count()) + 1, evernote_default_deck_label) - layout.insertWidget(int(layout.count()) + 2, evernote_default_deck) - evernote_default_deck.connect(evernote_default_deck, SIGNAL("editingFinished()"), update_evernote_default_deck) - - # Default Tag - evernote_default_tag_label = QLabel("Default Tag:") - evernote_default_tag = QLineEdit() - evernote_default_tag.setText(mw.col.conf.get(SETTING_DEFAULT_TAG, "")) - layout.insertWidget(int(layout.count()) + 1, evernote_default_tag_label) - layout.insertWidget(int(layout.count()) + 2, evernote_default_tag) - evernote_default_tag.connect(evernote_default_tag, SIGNAL("editingFinished()"), update_evernote_default_tag) - - # Tags to Import - evernote_tags_to_import_label = QLabel("Tags to Import:") - evernote_tags_to_import = QLineEdit() - evernote_tags_to_import.setText(mw.col.conf.get(SETTING_TAGS_TO_IMPORT, "")) - layout.insertWidget(int(layout.count()) + 1, evernote_tags_to_import_label) - layout.insertWidget(int(layout.count()) + 2, evernote_tags_to_import) - evernote_tags_to_import.connect(evernote_tags_to_import, - SIGNAL("editingFinished()"), - update_evernote_tags_to_import) - - # Keep Evernote Tags - keep_evernote_tags = QCheckBox("Keep Evernote Tags", self) - keep_evernote_tags.setChecked(mw.col.conf.get(SETTING_KEEP_TAGS, False)) - keep_evernote_tags.stateChanged.connect(update_evernote_keep_tags) - layout.insertWidget(int(layout.count()) + 1, keep_evernote_tags) - - # Update Existing Notes - update_existing_notes = QComboBox() - update_existing_notes.addItems(["Ignore Existing Notes", "Update Existing Notes In-Place", - "Delete and Re-Add Existing Notes"]) - update_existing_notes.setCurrentIndex(mw.col.conf.get(SETTING_UPDATE_EXISTING_NOTES, - UpdateExistingNotes.UpdateNotesInPlace)) - update_existing_notes.activated.connect(update_evernote_update_existing_notes) - layout.insertWidget(int(layout.count()) + 1, update_existing_notes) - - # Vertical Spacer - vertical_spacer = QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) - layout.addItem(vertical_spacer) - - # Parent Widget - widget.setLayout(layout) - - # New Tab - self.form.tabWidget.addTab(widget, "Evernote Importer") - -def update_evernote_default_deck(): - mw.col.conf[SETTING_DEFAULT_DECK] = evernote_default_deck.text() - -def update_evernote_default_tag(): - mw.col.conf[SETTING_DEFAULT_TAG] = evernote_default_tag.text() - -def update_evernote_tags_to_import(): - mw.col.conf[SETTING_TAGS_TO_IMPORT] = evernote_tags_to_import.text() - -def update_evernote_keep_tags(): - mw.col.conf[SETTING_KEEP_TAGS] = keep_evernote_tags.isChecked() - -def update_evernote_update_existing_notes(index): - mw.col.conf[SETTING_UPDATE_EXISTING_NOTES] = index - -Preferences.setupOptions = wrap(Preferences.setupOptions, setup_evernote) + m, s = divmod(importDelay / 1000, 60) + log("> Starting Auto Import, Triggered by Profile Load, in %d:%02d min" % (m, s)) + mw.progress.timer(importDelay, menu.import_from_evernote, False) + + +def _findEdited((val, args)): + try: days = int(val) + except ValueError: return + return "c.mod > %d" % (time.time() - days * 86400) + +class CallbackItem(QTreeWidgetItem): + def __init__(self, root, name, onclick, oncollapse=None): + QTreeWidgetItem.__init__(self, root, [name]) + self.onclick = onclick + self.oncollapse = oncollapse + +def anknotes_browser_tagtree_wrap(self, root, _old): + """ + + :param root: + :type root : QTreeWidget + :param _old: + :return: + """ + tags = [(_("Edited This Week"), "view-pim-calendar.png", "edited:7")] + for name, icon, cmd in tags: + onclick = lambda c=cmd: self.setFilter(c) + widgetItem = QTreeWidgetItem([name]) + widgetItem.onclick = onclick + widgetItem.setIcon(0, QIcon(":/icons/" + icon)) + root = _old(self, root) + indices = root.findItems(_("Added Today"), Qt.MatchFixedString) + index = (root.indexOfTopLevelItem(indices[0]) + 1) if indices else 3 + root.insertTopLevelItem(index, widgetItem) + return root + +def anknotes_search_hook(search): + if not 'edited' in search: + search['edited'] = _findEdited + +def anknotes_profile_loaded(): + menu.anknotes_load_menu_settings() + if ANKNOTES.ENABLE_VALIDATION and ANKNOTES.AUTOMATE_VALIDATION: + menu.upload_validated_notes(True) + import_timer_toggle() + if ANKNOTES.DEVELOPER_MODE_AUTOMATE: + ''' + For testing purposes only! + Add a function here and it will automatically run on profile load + You must create the files 'anknotes.developer' and 'anknotes.developer.automate' in the /extra/dev/ folder + ''' + # resync_with_local_db() + # menu.see_also() + # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) + # menu.see_also(3) + # menu.see_also(4) + # mw.progress.timer(20000, lambda : menu.find_deleted_notes(True), False) + # menu.see_also([3,4]) + menu.resync_with_local_db() + pass + + +def anknotes_onload(): + addHook("profileLoaded", anknotes_profile_loaded) + addHook("search", anknotes_search_hook) + browser.Browser._systemTagTree = wrap(browser.Browser._systemTagTree, anknotes_browser_tagtree_wrap, "around") + menu.anknotes_setup_menu() + Preferences.setupOptions = wrap(Preferences.setupOptions, settings.setup_evernote) + + +anknotes_onload() +# log("Anki Loaded", "load") diff --git a/anknotes/enum/LICENSE b/anknotes/enum/LICENSE new file mode 100644 index 0000000..9003b88 --- /dev/null +++ b/anknotes/enum/LICENSE @@ -0,0 +1,32 @@ +Copyright (c) 2013, Ethan Furman. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials + provided with the distribution. + + Neither the name Ethan Furman nor the names of any + contributors may be used to endorse or promote products + derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/anknotes/enum/README b/anknotes/enum/README new file mode 100644 index 0000000..511af98 --- /dev/null +++ b/anknotes/enum/README @@ -0,0 +1,2 @@ +enum34 is the new Python stdlib enum module available in Python 3.4 +backported for previous versions of Python from 2.4 to 3.3. diff --git a/anknotes/enum/__init__.py b/anknotes/enum/__init__.py new file mode 100644 index 0000000..6a327a8 --- /dev/null +++ b/anknotes/enum/__init__.py @@ -0,0 +1,790 @@ +"""Python Enumerations""" + +import sys as _sys + +__all__ = ['Enum', 'IntEnum', 'unique'] + +version = 1, 0, 4 + +pyver = float('%s.%s' % _sys.version_info[:2]) + +try: + any +except NameError: + def any(iterable): + for element in iterable: + if element: + return True + return False + +try: + from collections import OrderedDict +except ImportError: + OrderedDict = None + +try: + basestring +except NameError: + # In Python 2 basestring is the ancestor of both str and unicode + # in Python 3 it's just str, but was missing in 3.1 + basestring = str + +try: + unicode +except NameError: + # In Python 3 unicode no longer exists (it's just str) + unicode = str + +class _RouteClassAttributeToGetattr(object): + """Route attribute access on a class to __getattr__. + + This is a descriptor, used to define attributes that act differently when + accessed through an instance and through a class. Instance access remains + normal, but access to an attribute through a class will be routed to the + class's __getattr__ method; this is done by raising AttributeError. + + """ + def __init__(self, fget=None): + self.fget = fget + + def __get__(self, instance, ownerclass=None): + if instance is None: + raise AttributeError() + return self.fget(instance) + + def __set__(self, instance, value): + raise AttributeError("can't set attribute") + + def __delete__(self, instance): + raise AttributeError("can't delete attribute") + + +def _is_descriptor(obj): + """Returns True if obj is a descriptor, False otherwise.""" + return ( + hasattr(obj, '__get__') or + hasattr(obj, '__set__') or + hasattr(obj, '__delete__')) + + +def _is_dunder(name): + """Returns True if a __dunder__ name, False otherwise.""" + return (name[:2] == name[-2:] == '__' and + name[2:3] != '_' and + name[-3:-2] != '_' and + len(name) > 4) + + +def _is_sunder(name): + """Returns True if a _sunder_ name, False otherwise.""" + return (name[0] == name[-1] == '_' and + name[1:2] != '_' and + name[-2:-1] != '_' and + len(name) > 2) + + +def _make_class_unpicklable(cls): + """Make the given class un-picklable.""" + def _break_on_call_reduce(self, protocol=None): + raise TypeError('%r cannot be pickled' % self) + cls.__reduce_ex__ = _break_on_call_reduce + cls.__module__ = '<unknown>' + + +class _EnumDict(dict): + """Track enum member order and ensure member names are not reused. + + EnumMeta will use the names found in self._member_names as the + enumeration member names. + + """ + def __init__(self): + super(_EnumDict, self).__init__() + self._member_names = [] + + def __setitem__(self, key, value): + """Changes anything not dundered or not a descriptor. + + If a descriptor is added with the same name as an enum member, the name + is removed from _member_names (this may leave a hole in the numerical + sequence of values). + + If an enum member name is used twice, an error is raised; duplicate + values are not checked for. + + Single underscore (sunder) names are reserved. + + Note: in 3.x __order__ is simply discarded as a not necessary piece + leftover from 2.x + + """ + if pyver >= 3.0 and key == '__order__': + return + if _is_sunder(key): + raise ValueError('_names_ are reserved for future Enum use') + elif _is_dunder(key): + pass + elif key in self._member_names: + # descriptor overwriting an enum? + raise TypeError('Attempted to reuse key: %r' % key) + elif not _is_descriptor(value): + if key in self: + # enum overwriting a descriptor? + raise TypeError('Key already defined as: %r' % self[key]) + self._member_names.append(key) + super(_EnumDict, self).__setitem__(key, value) + + +# Dummy value for Enum as EnumMeta explicity checks for it, but of course until +# EnumMeta finishes running the first time the Enum class doesn't exist. This +# is also why there are checks in EnumMeta like `if Enum is not None` +Enum = None + + +class EnumMeta(type): + """Metaclass for Enum""" + @classmethod + def __prepare__(metacls, cls, bases): + return _EnumDict() + + def __new__(metacls, cls, bases, classdict): + # an Enum class is final once enumeration items have been defined; it + # cannot be mixed with other types (int, float, etc.) if it has an + # inherited __new__ unless a new __new__ is defined (or the resulting + # class will fail). + if type(classdict) is dict: + original_dict = classdict + classdict = _EnumDict() + for k, v in original_dict.items(): + classdict[k] = v + + member_type, first_enum = metacls._get_mixins_(bases) + __new__, save_new, use_args = metacls._find_new_(classdict, member_type, + first_enum) + # save enum items into separate mapping so they don't get baked into + # the new class + members = dict((k, classdict[k]) for k in classdict._member_names) + for name in classdict._member_names: + del classdict[name] + + # py2 support for definition order + __order__ = classdict.get('__order__') + if __order__ is None: + if pyver < 3.0: + try: + __order__ = [name for (name, value) in sorted(members.items(), key=lambda item: item[1])] + except TypeError: + __order__ = [name for name in sorted(members.keys())] + else: + __order__ = classdict._member_names + else: + del classdict['__order__'] + if pyver < 3.0: + __order__ = __order__.replace(',', ' ').split() + aliases = [name for name in members if name not in __order__] + __order__ += aliases + + # check for illegal enum names (any others?) + invalid_names = set(members) & set(['mro']) + if invalid_names: + raise ValueError('Invalid enum member name(s): %s' % ( + ', '.join(invalid_names), )) + + # create our new Enum type + enum_class = super(EnumMeta, metacls).__new__(metacls, cls, bases, classdict) + enum_class._member_names_ = [] # names in random order + if OrderedDict is not None: + enum_class._member_map_ = OrderedDict() + else: + enum_class._member_map_ = {} # name->value map + enum_class._member_type_ = member_type + + # Reverse value->name map for hashable values. + enum_class._value2member_map_ = {} + + # instantiate them, checking for duplicates as we go + # we instantiate first instead of checking for duplicates first in case + # a custom __new__ is doing something funky with the values -- such as + # auto-numbering ;) + if __new__ is None: + __new__ = enum_class.__new__ + for member_name in __order__: + value = members[member_name] + if not isinstance(value, tuple): + args = (value, ) + else: + args = value + if member_type is tuple: # special case for tuple enums + args = (args, ) # wrap it one more time + if not use_args or not args: + enum_member = __new__(enum_class) + if not hasattr(enum_member, '_value_'): + enum_member._value_ = value + else: + enum_member = __new__(enum_class, *args) + if not hasattr(enum_member, '_value_'): + enum_member._value_ = member_type(*args) + value = enum_member._value_ + enum_member._name_ = member_name + enum_member.__objclass__ = enum_class + enum_member.__init__(*args) + # If another member with the same value was already defined, the + # new member becomes an alias to the existing one. + for name, canonical_member in enum_class._member_map_.items(): + if canonical_member.value == enum_member._value_: + enum_member = canonical_member + break + else: + # Aliases don't appear in member names (only in __members__). + enum_class._member_names_.append(member_name) + enum_class._member_map_[member_name] = enum_member + try: + # This may fail if value is not hashable. We can't add the value + # to the map, and by-value lookups for this value will be + # linear. + enum_class._value2member_map_[value] = enum_member + except TypeError: + pass + + + # If a custom type is mixed into the Enum, and it does not know how + # to pickle itself, pickle.dumps will succeed but pickle.loads will + # fail. Rather than have the error show up later and possibly far + # from the source, sabotage the pickle protocol for this class so + # that pickle.dumps also fails. + # + # However, if the new class implements its own __reduce_ex__, do not + # sabotage -- it's on them to make sure it works correctly. We use + # __reduce_ex__ instead of any of the others as it is preferred by + # pickle over __reduce__, and it handles all pickle protocols. + unpicklable = False + if '__reduce_ex__' not in classdict: + if member_type is not object: + methods = ('__getnewargs_ex__', '__getnewargs__', + '__reduce_ex__', '__reduce__') + if not any(m in member_type.__dict__ for m in methods): + _make_class_unpicklable(enum_class) + unpicklable = True + + + # double check that repr and friends are not the mixin's or various + # things break (such as pickle) + for name in ('__repr__', '__str__', '__format__', '__reduce_ex__'): + class_method = getattr(enum_class, name) + obj_method = getattr(member_type, name, None) + enum_method = getattr(first_enum, name, None) + if name not in classdict and class_method is not enum_method: + if name == '__reduce_ex__' and unpicklable: + continue + setattr(enum_class, name, enum_method) + + # method resolution and int's are not playing nice + # Python's less than 2.6 use __cmp__ + + if pyver < 2.6: + + if issubclass(enum_class, int): + setattr(enum_class, '__cmp__', getattr(int, '__cmp__')) + + elif pyver < 3.0: + + if issubclass(enum_class, int): + for method in ( + '__le__', + '__lt__', + '__gt__', + '__ge__', + '__eq__', + '__ne__', + '__hash__', + ): + setattr(enum_class, method, getattr(int, method)) + + # replace any other __new__ with our own (as long as Enum is not None, + # anyway) -- again, this is to support pickle + if Enum is not None: + # if the user defined their own __new__, save it before it gets + # clobbered in case they subclass later + if save_new: + setattr(enum_class, '__member_new__', enum_class.__dict__['__new__']) + setattr(enum_class, '__new__', Enum.__dict__['__new__']) + return enum_class + + def __call__(cls, value, names=None, module=None, type=None): + """Either returns an existing member, or creates a new enum class. + + This method is used both when an enum class is given a value to match + to an enumeration member (i.e. Color(3)) and for the functional API + (i.e. Color = Enum('Color', names='red green blue')). + + When used for the functional API: `module`, if set, will be stored in + the new class' __module__ attribute; `type`, if set, will be mixed in + as the first base class. + + Note: if `module` is not set this routine will attempt to discover the + calling module by walking the frame stack; if this is unsuccessful + the resulting class will not be pickleable. + + """ + if names is None: # simple value lookup + return cls.__new__(cls, value) + # otherwise, functional API: we're creating a new Enum type + return cls._create_(value, names, module=module, type=type) + + def __contains__(cls, member): + return isinstance(member, cls) and member.name in cls._member_map_ + + def __delattr__(cls, attr): + # nicer error message when someone tries to delete an attribute + # (see issue19025). + if attr in cls._member_map_: + raise AttributeError( + "%s: cannot delete Enum member." % cls.__name__) + super(EnumMeta, cls).__delattr__(attr) + + def __dir__(self): + return (['__class__', '__doc__', '__members__', '__module__'] + + self._member_names_) + + @property + def __members__(cls): + """Returns a mapping of member name->value. + + This mapping lists all enum members, including aliases. Note that this + is a copy of the internal mapping. + + """ + return cls._member_map_.copy() + + def __getattr__(cls, name): + """Return the enum member matching `name` + + We use __getattr__ instead of descriptors or inserting into the enum + class' __dict__ in order to support `name` and `value` being both + properties for enum members (which live in the class' __dict__) and + enum members themselves. + + """ + if _is_dunder(name): + raise AttributeError(name) + try: + return cls._member_map_[name] + except KeyError: + raise AttributeError(name) + + def __getitem__(cls, name): + return cls._member_map_[name] + + def __iter__(cls): + return (cls._member_map_[name] for name in cls._member_names_) + + def __reversed__(cls): + return (cls._member_map_[name] for name in reversed(cls._member_names_)) + + def __len__(cls): + return len(cls._member_names_) + + def __repr__(cls): + return "<enum %r>" % cls.__name__ + + def __setattr__(cls, name, value): + """Block attempts to reassign Enum members. + + A simple assignment to the class namespace only changes one of the + several possible ways to get an Enum member from the Enum class, + resulting in an inconsistent Enumeration. + + """ + member_map = cls.__dict__.get('_member_map_', {}) + if name in member_map: + raise AttributeError('Cannot reassign members.') + super(EnumMeta, cls).__setattr__(name, value) + + def _create_(cls, class_name, names=None, module=None, type=None): + """Convenience method to create a new Enum class. + + `names` can be: + + * A string containing member names, separated either with spaces or + commas. Values are auto-numbered from 1. + * An iterable of member names. Values are auto-numbered from 1. + * An iterable of (member name, value) pairs. + * A mapping of member name -> value. + + """ + if pyver < 3.0: + # if class_name is unicode, attempt a conversion to ASCII + if isinstance(class_name, unicode): + try: + class_name = class_name.encode('ascii') + except UnicodeEncodeError: + raise TypeError('%r is not representable in ASCII' % class_name) + metacls = cls.__class__ + if type is None: + bases = (cls, ) + else: + bases = (type, cls) + classdict = metacls.__prepare__(class_name, bases) + __order__ = [] + + # special processing needed for names? + if isinstance(names, basestring): + names = names.replace(',', ' ').split() + if isinstance(names, (tuple, list)) and isinstance(names[0], basestring): + names = [(e, i+1) for (i, e) in enumerate(names)] + + # Here, names is either an iterable of (name, value) or a mapping. + for item in names: + if isinstance(item, basestring): + member_name, member_value = item, names[item] + else: + member_name, member_value = item + classdict[member_name] = member_value + __order__.append(member_name) + # only set __order__ in classdict if name/value was not from a mapping + if not isinstance(item, basestring): + classdict['__order__'] = ' '.join(__order__) + enum_class = metacls.__new__(metacls, class_name, bases, classdict) + + # TODO: replace the frame hack if a blessed way to know the calling + # module is ever developed + if module is None: + try: + module = _sys._getframe(2).f_globals['__name__'] + except (AttributeError, ValueError): + pass + if module is None: + _make_class_unpicklable(enum_class) + else: + enum_class.__module__ = module + + return enum_class + + @staticmethod + def _get_mixins_(bases): + """Returns the type for creating enum members, and the first inherited + enum class. + + bases: the tuple of bases that was given to __new__ + + """ + if not bases or Enum is None: + return object, Enum + + + # double check that we are not subclassing a class with existing + # enumeration members; while we're at it, see if any other data + # type has been mixed in so we can use the correct __new__ + member_type = first_enum = None + for base in bases: + if (base is not Enum and + issubclass(base, Enum) and + base._member_names_): + raise TypeError("Cannot extend enumerations") + # base is now the last base in bases + if not issubclass(base, Enum): + raise TypeError("new enumerations must be created as " + "`ClassName([mixin_type,] enum_type)`") + + # get correct mix-in type (either mix-in type of Enum subclass, or + # first base if last base is Enum) + if not issubclass(bases[0], Enum): + member_type = bases[0] # first data type + first_enum = bases[-1] # enum type + else: + for base in bases[0].__mro__: + # most common: (IntEnum, int, Enum, object) + # possible: (<Enum 'AutoIntEnum'>, <Enum 'IntEnum'>, + # <class 'int'>, <Enum 'Enum'>, + # <class 'object'>) + if issubclass(base, Enum): + if first_enum is None: + first_enum = base + else: + if member_type is None: + member_type = base + + return member_type, first_enum + + if pyver < 3.0: + @staticmethod + def _find_new_(classdict, member_type, first_enum): + """Returns the __new__ to be used for creating the enum members. + + classdict: the class dictionary given to __new__ + member_type: the data type whose __new__ will be used by default + first_enum: enumeration to check for an overriding __new__ + + """ + # now find the correct __new__, checking to see of one was defined + # by the user; also check earlier enum classes in case a __new__ was + # saved as __member_new__ + __new__ = classdict.get('__new__', None) + if __new__: + return None, True, True # __new__, save_new, use_args + + N__new__ = getattr(None, '__new__') + O__new__ = getattr(object, '__new__') + if Enum is None: + E__new__ = N__new__ + else: + E__new__ = Enum.__dict__['__new__'] + # check all possibles for __member_new__ before falling back to + # __new__ + for method in ('__member_new__', '__new__'): + for possible in (member_type, first_enum): + try: + target = possible.__dict__[method] + except (AttributeError, KeyError): + target = getattr(possible, method, None) + if target not in [ + None, + N__new__, + O__new__, + E__new__, + ]: + if method == '__member_new__': + classdict['__new__'] = target + return None, False, True + if isinstance(target, staticmethod): + target = target.__get__(member_type) + __new__ = target + break + if __new__ is not None: + break + else: + __new__ = object.__new__ + + # if a non-object.__new__ is used then whatever value/tuple was + # assigned to the enum member name will be passed to __new__ and to the + # new enum member's __init__ + if __new__ is object.__new__: + use_args = False + else: + use_args = True + + return __new__, False, use_args + else: + @staticmethod + def _find_new_(classdict, member_type, first_enum): + """Returns the __new__ to be used for creating the enum members. + + classdict: the class dictionary given to __new__ + member_type: the data type whose __new__ will be used by default + first_enum: enumeration to check for an overriding __new__ + + """ + # now find the correct __new__, checking to see of one was defined + # by the user; also check earlier enum classes in case a __new__ was + # saved as __member_new__ + __new__ = classdict.get('__new__', None) + + # should __new__ be saved as __member_new__ later? + save_new = __new__ is not None + + if __new__ is None: + # check all possibles for __member_new__ before falling back to + # __new__ + for method in ('__member_new__', '__new__'): + for possible in (member_type, first_enum): + target = getattr(possible, method, None) + if target not in ( + None, + None.__new__, + object.__new__, + Enum.__new__, + ): + __new__ = target + break + if __new__ is not None: + break + else: + __new__ = object.__new__ + + # if a non-object.__new__ is used then whatever value/tuple was + # assigned to the enum member name will be passed to __new__ and to the + # new enum member's __init__ + if __new__ is object.__new__: + use_args = False + else: + use_args = True + + return __new__, save_new, use_args + + +######################################################## +# In order to support Python 2 and 3 with a single +# codebase we have to create the Enum methods separately +# and then use the `type(name, bases, dict)` method to +# create the class. +######################################################## +temp_enum_dict = {} +temp_enum_dict['__doc__'] = "Generic enumeration.\n\n Derive from this class to define new enumerations.\n\n" + +def __new__(cls, value): + # all enum instances are actually created during class construction + # without calling this method; this method is called by the metaclass' + # __call__ (i.e. Color(3) ), and by pickle + if type(value) is cls: + # For lookups like Color(Color.red) + value = value.value + #return value + # by-value search for a matching enum member + # see if it's in the reverse mapping (for hashable values) + try: + if value in cls._value2member_map_: + return cls._value2member_map_[value] + except TypeError: + # not there, now do long search -- O(n) behavior + for member in cls._member_map_.values(): + if member.value == value: + return member + raise ValueError("%s is not a valid %s" % (value, cls.__name__)) +temp_enum_dict['__new__'] = __new__ +del __new__ + +def __repr__(self): + return "<%s.%s: %r>" % ( + self.__class__.__name__, self._name_, self._value_) +temp_enum_dict['__repr__'] = __repr__ +del __repr__ + +def __str__(self): + return "%s.%s" % (self.__class__.__name__, self._name_) +temp_enum_dict['__str__'] = __str__ +del __str__ + +def __dir__(self): + added_behavior = [ + m + for cls in self.__class__.mro() + for m in cls.__dict__ + if m[0] != '_' + ] + return (['__class__', '__doc__', '__module__', ] + added_behavior) +temp_enum_dict['__dir__'] = __dir__ +del __dir__ + +def __format__(self, format_spec): + # mixed-in Enums should use the mixed-in type's __format__, otherwise + # we can get strange results with the Enum name showing up instead of + # the value + + # pure Enum branch + if self._member_type_ is object: + cls = str + val = str(self) + # mix-in branch + else: + cls = self._member_type_ + val = self.value + return cls.__format__(val, format_spec) +temp_enum_dict['__format__'] = __format__ +del __format__ + + +#################################### +# Python's less than 2.6 use __cmp__ + +if pyver < 2.6: + + def __cmp__(self, other): + if type(other) is self.__class__: + if self is other: + return 0 + return -1 + return NotImplemented + raise TypeError("unorderable types: %s() and %s()" % (self.__class__.__name__, other.__class__.__name__)) + temp_enum_dict['__cmp__'] = __cmp__ + del __cmp__ + +else: + + def __le__(self, other): + raise TypeError("unorderable types: %s() <= %s()" % (self.__class__.__name__, other.__class__.__name__)) + temp_enum_dict['__le__'] = __le__ + del __le__ + + def __lt__(self, other): + raise TypeError("unorderable types: %s() < %s()" % (self.__class__.__name__, other.__class__.__name__)) + temp_enum_dict['__lt__'] = __lt__ + del __lt__ + + def __ge__(self, other): + raise TypeError("unorderable types: %s() >= %s()" % (self.__class__.__name__, other.__class__.__name__)) + temp_enum_dict['__ge__'] = __ge__ + del __ge__ + + def __gt__(self, other): + raise TypeError("unorderable types: %s() > %s()" % (self.__class__.__name__, other.__class__.__name__)) + temp_enum_dict['__gt__'] = __gt__ + del __gt__ + + +def __eq__(self, other): + if type(other) is self.__class__: + return self is other + return NotImplemented +temp_enum_dict['__eq__'] = __eq__ +del __eq__ + +def __ne__(self, other): + if type(other) is self.__class__: + return self is not other + return NotImplemented +temp_enum_dict['__ne__'] = __ne__ +del __ne__ + +def __hash__(self): + return hash(self._name_) +temp_enum_dict['__hash__'] = __hash__ +del __hash__ + +def __reduce_ex__(self, proto): + return self.__class__, (self._value_, ) +temp_enum_dict['__reduce_ex__'] = __reduce_ex__ +del __reduce_ex__ + +# _RouteClassAttributeToGetattr is used to provide access to the `name` +# and `value` properties of enum members while keeping some measure of +# protection from modification, while still allowing for an enumeration +# to have members named `name` and `value`. This works because enumeration +# members are not set directly on the enum class -- __getattr__ is +# used to look them up. + +@_RouteClassAttributeToGetattr +def name(self): + return self._name_ +temp_enum_dict['name'] = name +del name + +@_RouteClassAttributeToGetattr +def value(self): + return self._value_ +temp_enum_dict['value'] = value +del value + +Enum = EnumMeta('Enum', (object, ), temp_enum_dict) +del temp_enum_dict + +# Enum has now been created +########################### + +class IntEnum(int, Enum): + """Enum where members are also (and must be) ints""" + + +def unique(enumeration): + """Class decorator that ensures only unique members exist in an enumeration.""" + duplicates = [] + for name, member in enumeration.__members__.items(): + if name != member.name: + duplicates.append((name, member.name)) + if duplicates: + duplicate_names = ', '.join( + ["%s -> %s" % (alias, name) for (alias, name) in duplicates] + ) + raise ValueError('duplicate names found in %r: %s' % + (enumeration, duplicate_names) + ) + return enumeration diff --git a/anknotes/enum/doc/enum.rst b/anknotes/enum/doc/enum.rst new file mode 100644 index 0000000..0d429bf --- /dev/null +++ b/anknotes/enum/doc/enum.rst @@ -0,0 +1,725 @@ +``enum`` --- support for enumerations +======================================== + +.. :synopsis: enumerations are sets of symbolic names bound to unique, constant + values. +.. :moduleauthor:: Ethan Furman <ethan@stoneleaf.us> +.. :sectionauthor:: Barry Warsaw <barry@python.org>, +.. :sectionauthor:: Eli Bendersky <eliben@gmail.com>, +.. :sectionauthor:: Ethan Furman <ethan@stoneleaf.us> + +---------------- + +An enumeration is a set of symbolic names (members) bound to unique, constant +values. Within an enumeration, the members can be compared by identity, and +the enumeration itself can be iterated over. + + +Module Contents +--------------- + +This module defines two enumeration classes that can be used to define unique +sets of names and values: ``Enum`` and ``IntEnum``. It also defines +one decorator, ``unique``. + +``Enum`` + +Base class for creating enumerated constants. See section `Functional API`_ +for an alternate construction syntax. + +``IntEnum`` + +Base class for creating enumerated constants that are also subclasses of ``int``. + +``unique`` + +Enum class decorator that ensures only one name is bound to any one value. + + +Creating an Enum +---------------- + +Enumerations are created using the ``class`` syntax, which makes them +easy to read and write. An alternative creation method is described in +`Functional API`_. To define an enumeration, subclass ``Enum`` as +follows:: + + >>> from enum import Enum + >>> class Color(Enum): + ... red = 1 + ... green = 2 + ... blue = 3 + +Note: Nomenclature + + - The class ``Color`` is an *enumeration* (or *enum*) + - The attributes ``Color.red``, ``Color.green``, etc., are + *enumeration members* (or *enum members*). + - The enum members have *names* and *values* (the name of + ``Color.red`` is ``red``, the value of ``Color.blue`` is + ``3``, etc.) + +Note: + + Even though we use the ``class`` syntax to create Enums, Enums + are not normal Python classes. See `How are Enums different?`_ for + more details. + +Enumeration members have human readable string representations:: + + >>> print(Color.red) + Color.red + +...while their ``repr`` has more information:: + + >>> print(repr(Color.red)) + <Color.red: 1> + +The *type* of an enumeration member is the enumeration it belongs to:: + + >>> type(Color.red) + <enum 'Color'> + >>> isinstance(Color.green, Color) + True + >>> + +Enum members also have a property that contains just their item name:: + + >>> print(Color.red.name) + red + +Enumerations support iteration. In Python 3.x definition order is used; in +Python 2.x the definition order is not available, but class attribute +``__order__`` is supported; otherwise, value order is used:: + + >>> class Shake(Enum): + ... __order__ = 'vanilla chocolate cookies mint' # only needed in 2.x + ... vanilla = 7 + ... chocolate = 4 + ... cookies = 9 + ... mint = 3 + ... + >>> for shake in Shake: + ... print(shake) + ... + Shake.vanilla + Shake.chocolate + Shake.cookies + Shake.mint + +The ``__order__`` attribute is always removed, and in 3.x it is also ignored +(order is definition order); however, in the stdlib version it will be ignored +but not removed. + +Enumeration members are hashable, so they can be used in dictionaries and sets:: + + >>> apples = {} + >>> apples[Color.red] = 'red delicious' + >>> apples[Color.green] = 'granny smith' + >>> apples == {Color.red: 'red delicious', Color.green: 'granny smith'} + True + + +Programmatic access to enumeration members and their attributes +--------------------------------------------------------------- + +Sometimes it's useful to access members in enumerations programmatically (i.e. +situations where ``Color.red`` won't do because the exact color is not known +at program-writing time). ``Enum`` allows such access:: + + >>> Color(1) + <Color.red: 1> + >>> Color(3) + <Color.blue: 3> + +If you want to access enum members by *name*, use item access:: + + >>> Color['red'] + <Color.red: 1> + >>> Color['green'] + <Color.green: 2> + +If have an enum member and need its ``name`` or ``value``:: + + >>> member = Color.red + >>> member.name + 'red' + >>> member.value + 1 + + +Duplicating enum members and values +----------------------------------- + +Having two enum members (or any other attribute) with the same name is invalid; +in Python 3.x this would raise an error, but in Python 2.x the second member +simply overwrites the first:: + + >>> # python 2.x + >>> class Shape(Enum): + ... square = 2 + ... square = 3 + ... + >>> Shape.square + <Shape.square: 3> + + >>> # python 3.x + >>> class Shape(Enum): + ... square = 2 + ... square = 3 + Traceback (most recent call last): + ... + TypeError: Attempted to reuse key: 'square' + +However, two enum members are allowed to have the same value. Given two members +A and B with the same value (and A defined first), B is an alias to A. By-value +lookup of the value of A and B will return A. By-name lookup of B will also +return A:: + + >>> class Shape(Enum): + ... __order__ = 'square diamond circle alias_for_square' # only needed in 2.x + ... square = 2 + ... diamond = 1 + ... circle = 3 + ... alias_for_square = 2 + ... + >>> Shape.square + <Shape.square: 2> + >>> Shape.alias_for_square + <Shape.square: 2> + >>> Shape(2) + <Shape.square: 2> + + +Allowing aliases is not always desirable. ``unique`` can be used to ensure +that none exist in a particular enumeration:: + + >>> from enum import unique + >>> @unique + ... class Mistake(Enum): + ... __order__ = 'one two three four' # only needed in 2.x + ... one = 1 + ... two = 2 + ... three = 3 + ... four = 3 + Traceback (most recent call last): + ... + ValueError: duplicate names found in <enum 'Mistake'>: four -> three + +Iterating over the members of an enum does not provide the aliases:: + + >>> list(Shape) + [<Shape.square: 2>, <Shape.diamond: 1>, <Shape.circle: 3>] + +The special attribute ``__members__`` is a dictionary mapping names to members. +It includes all names defined in the enumeration, including the aliases:: + + >>> for name, member in sorted(Shape.__members__.items()): + ... name, member + ... + ('alias_for_square', <Shape.square: 2>) + ('circle', <Shape.circle: 3>) + ('diamond', <Shape.diamond: 1>) + ('square', <Shape.square: 2>) + +The ``__members__`` attribute can be used for detailed programmatic access to +the enumeration members. For example, finding all the aliases:: + + >>> [name for name, member in Shape.__members__.items() if member.name != name] + ['alias_for_square'] + +Comparisons +----------- + +Enumeration members are compared by identity:: + + >>> Color.red is Color.red + True + >>> Color.red is Color.blue + False + >>> Color.red is not Color.blue + True + +Ordered comparisons between enumeration values are *not* supported. Enum +members are not integers (but see `IntEnum`_ below):: + + >>> Color.red < Color.blue + Traceback (most recent call last): + File "<stdin>", line 1, in <module> + TypeError: unorderable types: Color() < Color() + +.. warning:: + + In Python 2 *everything* is ordered, even though the ordering may not + make sense. If you want your enumerations to have a sensible ordering + check out the `OrderedEnum`_ recipe below. + + +Equality comparisons are defined though:: + + >>> Color.blue == Color.red + False + >>> Color.blue != Color.red + True + >>> Color.blue == Color.blue + True + +Comparisons against non-enumeration values will always compare not equal +(again, ``IntEnum`` was explicitly designed to behave differently, see +below):: + + >>> Color.blue == 2 + False + + +Allowed members and attributes of enumerations +---------------------------------------------- + +The examples above use integers for enumeration values. Using integers is +short and handy (and provided by default by the `Functional API`_), but not +strictly enforced. In the vast majority of use-cases, one doesn't care what +the actual value of an enumeration is. But if the value *is* important, +enumerations can have arbitrary values. + +Enumerations are Python classes, and can have methods and special methods as +usual. If we have this enumeration:: + + >>> class Mood(Enum): + ... funky = 1 + ... happy = 3 + ... + ... def describe(self): + ... # self is the member here + ... return self.name, self.value + ... + ... def __str__(self): + ... return 'my custom str! {0}'.format(self.value) + ... + ... @classmethod + ... def favorite_mood(cls): + ... # cls here is the enumeration + ... return cls.happy + +Then:: + + >>> Mood.favorite_mood() + <Mood.happy: 3> + >>> Mood.happy.describe() + ('happy', 3) + >>> str(Mood.funky) + 'my custom str! 1' + +The rules for what is allowed are as follows: _sunder_ names (starting and +ending with a single underscore) are reserved by enum and cannot be used; +all other attributes defined within an enumeration will become members of this +enumeration, with the exception of *__dunder__* names and descriptors (methods +are also descriptors). + +Note: + + If your enumeration defines ``__new__`` and/or ``__init__`` then + whatever value(s) were given to the enum member will be passed into + those methods. See `Planet`_ for an example. + + +Restricted subclassing of enumerations +-------------------------------------- + +Subclassing an enumeration is allowed only if the enumeration does not define +any members. So this is forbidden:: + + >>> class MoreColor(Color): + ... pink = 17 + Traceback (most recent call last): + ... + TypeError: Cannot extend enumerations + +But this is allowed:: + + >>> class Foo(Enum): + ... def some_behavior(self): + ... pass + ... + >>> class Bar(Foo): + ... happy = 1 + ... sad = 2 + ... + +Allowing subclassing of enums that define members would lead to a violation of +some important invariants of types and instances. On the other hand, it makes +sense to allow sharing some common behavior between a group of enumerations. +(See `OrderedEnum`_ for an example.) + + +Pickling +-------- + +Enumerations can be pickled and unpickled:: + + >>> from enum.test_enum import Fruit + >>> from pickle import dumps, loads + >>> Fruit.tomato is loads(dumps(Fruit.tomato, 2)) + True + +The usual restrictions for pickling apply: picklable enums must be defined in +the top level of a module, since unpickling requires them to be importable +from that module. + +Note: + + With pickle protocol version 4 (introduced in Python 3.4) it is possible + to easily pickle enums nested in other classes. + + + +Functional API +-------------- + +The ``Enum`` class is callable, providing the following functional API:: + + >>> Animal = Enum('Animal', 'ant bee cat dog') + >>> Animal + <enum 'Animal'> + >>> Animal.ant + <Animal.ant: 1> + >>> Animal.ant.value + 1 + >>> list(Animal) + [<Animal.ant: 1>, <Animal.bee: 2>, <Animal.cat: 3>, <Animal.dog: 4>] + +The semantics of this API resemble ``namedtuple``. The first argument +of the call to ``Enum`` is the name of the enumeration. + +The second argument is the *source* of enumeration member names. It can be a +whitespace-separated string of names, a sequence of names, a sequence of +2-tuples with key/value pairs, or a mapping (e.g. dictionary) of names to +values. The last two options enable assigning arbitrary values to +enumerations; the others auto-assign increasing integers starting with 1. A +new class derived from ``Enum`` is returned. In other words, the above +assignment to ``Animal`` is equivalent to:: + + >>> class Animals(Enum): + ... ant = 1 + ... bee = 2 + ... cat = 3 + ... dog = 4 + +Pickling enums created with the functional API can be tricky as frame stack +implementation details are used to try and figure out which module the +enumeration is being created in (e.g. it will fail if you use a utility +function in separate module, and also may not work on IronPython or Jython). +The solution is to specify the module name explicitly as follows:: + + >>> Animals = Enum('Animals', 'ant bee cat dog', module=__name__) + +Derived Enumerations +-------------------- + +IntEnum +^^^^^^^ + +A variation of ``Enum`` is provided which is also a subclass of +``int``. Members of an ``IntEnum`` can be compared to integers; +by extension, integer enumerations of different types can also be compared +to each other:: + + >>> from enum import IntEnum + >>> class Shape(IntEnum): + ... circle = 1 + ... square = 2 + ... + >>> class Request(IntEnum): + ... post = 1 + ... get = 2 + ... + >>> Shape == 1 + False + >>> Shape.circle == 1 + True + >>> Shape.circle == Request.post + True + +However, they still can't be compared to standard ``Enum`` enumerations:: + + >>> class Shape(IntEnum): + ... circle = 1 + ... square = 2 + ... + >>> class Color(Enum): + ... red = 1 + ... green = 2 + ... + >>> Shape.circle == Color.red + False + +``IntEnum`` values behave like integers in other ways you'd expect:: + + >>> int(Shape.circle) + 1 + >>> ['a', 'b', 'c'][Shape.circle] + 'b' + >>> [i for i in range(Shape.square)] + [0, 1] + +For the vast majority of code, ``Enum`` is strongly recommended, +since ``IntEnum`` breaks some semantic promises of an enumeration (by +being comparable to integers, and thus by transitivity to other +unrelated enumerations). It should be used only in special cases where +there's no other choice; for example, when integer constants are +replaced with enumerations and backwards compatibility is required with code +that still expects integers. + + +Others +^^^^^^ + +While ``IntEnum`` is part of the ``enum`` module, it would be very +simple to implement independently:: + + class IntEnum(int, Enum): + pass + +This demonstrates how similar derived enumerations can be defined; for example +a ``StrEnum`` that mixes in ``str`` instead of ``int``. + +Some rules: + +1. When subclassing ``Enum``, mix-in types must appear before + ``Enum`` itself in the sequence of bases, as in the ``IntEnum`` + example above. +2. While ``Enum`` can have members of any type, once you mix in an + additional type, all the members must have values of that type, e.g. + ``int`` above. This restriction does not apply to mix-ins which only + add methods and don't specify another data type such as ``int`` or + ``str``. +3. When another data type is mixed in, the ``value`` attribute is *not the + same* as the enum member itself, although it is equivalant and will compare + equal. +4. %-style formatting: ``%s`` and ``%r`` call ``Enum``'s ``__str__`` and + ``__repr__`` respectively; other codes (such as ``%i`` or ``%h`` for + IntEnum) treat the enum member as its mixed-in type. + + Note: Prior to Python 3.4 there is a bug in ``str``'s %-formatting: ``int`` + subclasses are printed as strings and not numbers when the ``%d``, ``%i``, + or ``%u`` codes are used. +5. ``str.__format__`` (or ``format``) will use the mixed-in + type's ``__format__``. If the ``Enum``'s ``str`` or + ``repr`` is desired use the ``!s`` or ``!r`` ``str`` format codes. + + +Decorators +---------- + +unique +^^^^^^ + +A ``class`` decorator specifically for enumerations. It searches an +enumeration's ``__members__`` gathering any aliases it finds; if any are +found ``ValueError`` is raised with the details:: + + >>> @unique + ... class NoDupes(Enum): + ... first = 'one' + ... second = 'two' + ... third = 'two' + Traceback (most recent call last): + ... + ValueError: duplicate names found in <enum 'NoDupes'>: third -> second + + +Interesting examples +-------------------- + +While ``Enum`` and ``IntEnum`` are expected to cover the majority of +use-cases, they cannot cover them all. Here are recipes for some different +types of enumerations that can be used directly, or as examples for creating +one's own. + + +AutoNumber +^^^^^^^^^^ + +Avoids having to specify the value for each enumeration member:: + + >>> class AutoNumber(Enum): + ... def __new__(cls): + ... value = len(cls.__members__) + 1 + ... obj = object.__new__(cls) + ... obj._value_ = value + ... return obj + ... + >>> class Color(AutoNumber): + ... __order__ = "red green blue" # only needed in 2.x + ... red = () + ... green = () + ... blue = () + ... + >>> Color.green.value == 2 + True + +Note: + + The `__new__` method, if defined, is used during creation of the Enum + members; it is then replaced by Enum's `__new__` which is used after + class creation for lookup of existing members. Due to the way Enums are + supposed to behave, there is no way to customize Enum's `__new__`. + + +UniqueEnum +^^^^^^^^^^ + +Raises an error if a duplicate member name is found instead of creating an +alias:: + + >>> class UniqueEnum(Enum): + ... def __init__(self, *args): + ... cls = self.__class__ + ... if any(self.value == e.value for e in cls): + ... a = self.name + ... e = cls(self.value).name + ... raise ValueError( + ... "aliases not allowed in UniqueEnum: %r --> %r" + ... % (a, e)) + ... + >>> class Color(UniqueEnum): + ... red = 1 + ... green = 2 + ... blue = 3 + ... grene = 2 + Traceback (most recent call last): + ... + ValueError: aliases not allowed in UniqueEnum: 'grene' --> 'green' + + +OrderedEnum +^^^^^^^^^^^ + +An ordered enumeration that is not based on ``IntEnum`` and so maintains +the normal ``Enum`` invariants (such as not being comparable to other +enumerations):: + + >>> class OrderedEnum(Enum): + ... def __ge__(self, other): + ... if self.__class__ is other.__class__: + ... return self._value_ >= other._value_ + ... return NotImplemented + ... def __gt__(self, other): + ... if self.__class__ is other.__class__: + ... return self._value_ > other._value_ + ... return NotImplemented + ... def __le__(self, other): + ... if self.__class__ is other.__class__: + ... return self._value_ <= other._value_ + ... return NotImplemented + ... def __lt__(self, other): + ... if self.__class__ is other.__class__: + ... return self._value_ < other._value_ + ... return NotImplemented + ... + >>> class Grade(OrderedEnum): + ... __ordered__ = 'A B C D F' + ... A = 5 + ... B = 4 + ... C = 3 + ... D = 2 + ... F = 1 + ... + >>> Grade.C < Grade.A + True + + +Planet +^^^^^^ + +If ``__new__`` or ``__init__`` is defined the value of the enum member +will be passed to those methods:: + + >>> class Planet(Enum): + ... MERCURY = (3.303e+23, 2.4397e6) + ... VENUS = (4.869e+24, 6.0518e6) + ... EARTH = (5.976e+24, 6.37814e6) + ... MARS = (6.421e+23, 3.3972e6) + ... JUPITER = (1.9e+27, 7.1492e7) + ... SATURN = (5.688e+26, 6.0268e7) + ... URANUS = (8.686e+25, 2.5559e7) + ... NEPTUNE = (1.024e+26, 2.4746e7) + ... def __init__(self, mass, radius): + ... self.mass = mass # in kilograms + ... self.radius = radius # in meters + ... @property + ... def surface_gravity(self): + ... # universal gravitational constant (m3 kg-1 s-2) + ... G = 6.67300E-11 + ... return G * self.mass / (self.radius * self.radius) + ... + >>> Planet.EARTH.value + (5.976e+24, 6378140.0) + >>> Planet.EARTH.surface_gravity + 9.802652743337129 + + +How are Enums different? +------------------------ + +Enums have a custom metaclass that affects many aspects of both derived Enum +classes and their instances (members). + + +Enum Classes +^^^^^^^^^^^^ + +The ``EnumMeta`` metaclass is responsible for providing the +``__contains__``, ``__dir__``, ``__iter__`` and other methods that +allow one to do things with an ``Enum`` class that fail on a typical +class, such as ``list(Color)`` or ``some_var in Color``. ``EnumMeta`` is +responsible for ensuring that various other methods on the final ``Enum`` +class are correct (such as ``__new__``, ``__getnewargs__``, +``__str__`` and ``__repr__``) + + +Enum Members (aka instances) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The most interesting thing about Enum members is that they are singletons. +``EnumMeta`` creates them all while it is creating the ``Enum`` +class itself, and then puts a custom ``__new__`` in place to ensure +that no new ones are ever instantiated by returning only the existing +member instances. + + +Finer Points +^^^^^^^^^^^^ + +Enum members are instances of an Enum class, and even though they are +accessible as ``EnumClass.member``, they are not accessible directly from +the member:: + + >>> Color.red + <Color.red: 1> + >>> Color.red.blue + Traceback (most recent call last): + ... + AttributeError: 'Color' object has no attribute 'blue' + +Likewise, ``__members__`` is only available on the class. + +In Python 3.x ``__members__`` is always an ``OrderedDict``, with the order being +the definition order. In Python 2.7 ``__members__`` is an ``OrderedDict`` if +``__order__`` was specified, and a plain ``dict`` otherwise. In all other Python +2.x versions ``__members__`` is a plain ``dict`` even if ``__order__`` was specified +as the ``OrderedDict`` type didn't exist yet. + +If you give your ``Enum`` subclass extra methods, like the `Planet`_ +class above, those methods will show up in a `dir` of the member, +but not of the class:: + + >>> dir(Planet) + ['EARTH', 'JUPITER', 'MARS', 'MERCURY', 'NEPTUNE', 'SATURN', 'URANUS', + 'VENUS', '__class__', '__doc__', '__members__', '__module__'] + >>> dir(Planet.EARTH) + ['__class__', '__doc__', '__module__', 'name', 'surface_gravity', 'value'] + +A ``__new__`` method will only be used for the creation of the +``Enum`` members -- after that it is replaced. This means if you wish to +change how ``Enum`` members are looked up you either have to write a +helper function or a ``classmethod``. diff --git a/anknotes/enum/enum.py b/anknotes/enum/enum.py new file mode 100644 index 0000000..6a327a8 --- /dev/null +++ b/anknotes/enum/enum.py @@ -0,0 +1,790 @@ +"""Python Enumerations""" + +import sys as _sys + +__all__ = ['Enum', 'IntEnum', 'unique'] + +version = 1, 0, 4 + +pyver = float('%s.%s' % _sys.version_info[:2]) + +try: + any +except NameError: + def any(iterable): + for element in iterable: + if element: + return True + return False + +try: + from collections import OrderedDict +except ImportError: + OrderedDict = None + +try: + basestring +except NameError: + # In Python 2 basestring is the ancestor of both str and unicode + # in Python 3 it's just str, but was missing in 3.1 + basestring = str + +try: + unicode +except NameError: + # In Python 3 unicode no longer exists (it's just str) + unicode = str + +class _RouteClassAttributeToGetattr(object): + """Route attribute access on a class to __getattr__. + + This is a descriptor, used to define attributes that act differently when + accessed through an instance and through a class. Instance access remains + normal, but access to an attribute through a class will be routed to the + class's __getattr__ method; this is done by raising AttributeError. + + """ + def __init__(self, fget=None): + self.fget = fget + + def __get__(self, instance, ownerclass=None): + if instance is None: + raise AttributeError() + return self.fget(instance) + + def __set__(self, instance, value): + raise AttributeError("can't set attribute") + + def __delete__(self, instance): + raise AttributeError("can't delete attribute") + + +def _is_descriptor(obj): + """Returns True if obj is a descriptor, False otherwise.""" + return ( + hasattr(obj, '__get__') or + hasattr(obj, '__set__') or + hasattr(obj, '__delete__')) + + +def _is_dunder(name): + """Returns True if a __dunder__ name, False otherwise.""" + return (name[:2] == name[-2:] == '__' and + name[2:3] != '_' and + name[-3:-2] != '_' and + len(name) > 4) + + +def _is_sunder(name): + """Returns True if a _sunder_ name, False otherwise.""" + return (name[0] == name[-1] == '_' and + name[1:2] != '_' and + name[-2:-1] != '_' and + len(name) > 2) + + +def _make_class_unpicklable(cls): + """Make the given class un-picklable.""" + def _break_on_call_reduce(self, protocol=None): + raise TypeError('%r cannot be pickled' % self) + cls.__reduce_ex__ = _break_on_call_reduce + cls.__module__ = '<unknown>' + + +class _EnumDict(dict): + """Track enum member order and ensure member names are not reused. + + EnumMeta will use the names found in self._member_names as the + enumeration member names. + + """ + def __init__(self): + super(_EnumDict, self).__init__() + self._member_names = [] + + def __setitem__(self, key, value): + """Changes anything not dundered or not a descriptor. + + If a descriptor is added with the same name as an enum member, the name + is removed from _member_names (this may leave a hole in the numerical + sequence of values). + + If an enum member name is used twice, an error is raised; duplicate + values are not checked for. + + Single underscore (sunder) names are reserved. + + Note: in 3.x __order__ is simply discarded as a not necessary piece + leftover from 2.x + + """ + if pyver >= 3.0 and key == '__order__': + return + if _is_sunder(key): + raise ValueError('_names_ are reserved for future Enum use') + elif _is_dunder(key): + pass + elif key in self._member_names: + # descriptor overwriting an enum? + raise TypeError('Attempted to reuse key: %r' % key) + elif not _is_descriptor(value): + if key in self: + # enum overwriting a descriptor? + raise TypeError('Key already defined as: %r' % self[key]) + self._member_names.append(key) + super(_EnumDict, self).__setitem__(key, value) + + +# Dummy value for Enum as EnumMeta explicity checks for it, but of course until +# EnumMeta finishes running the first time the Enum class doesn't exist. This +# is also why there are checks in EnumMeta like `if Enum is not None` +Enum = None + + +class EnumMeta(type): + """Metaclass for Enum""" + @classmethod + def __prepare__(metacls, cls, bases): + return _EnumDict() + + def __new__(metacls, cls, bases, classdict): + # an Enum class is final once enumeration items have been defined; it + # cannot be mixed with other types (int, float, etc.) if it has an + # inherited __new__ unless a new __new__ is defined (or the resulting + # class will fail). + if type(classdict) is dict: + original_dict = classdict + classdict = _EnumDict() + for k, v in original_dict.items(): + classdict[k] = v + + member_type, first_enum = metacls._get_mixins_(bases) + __new__, save_new, use_args = metacls._find_new_(classdict, member_type, + first_enum) + # save enum items into separate mapping so they don't get baked into + # the new class + members = dict((k, classdict[k]) for k in classdict._member_names) + for name in classdict._member_names: + del classdict[name] + + # py2 support for definition order + __order__ = classdict.get('__order__') + if __order__ is None: + if pyver < 3.0: + try: + __order__ = [name for (name, value) in sorted(members.items(), key=lambda item: item[1])] + except TypeError: + __order__ = [name for name in sorted(members.keys())] + else: + __order__ = classdict._member_names + else: + del classdict['__order__'] + if pyver < 3.0: + __order__ = __order__.replace(',', ' ').split() + aliases = [name for name in members if name not in __order__] + __order__ += aliases + + # check for illegal enum names (any others?) + invalid_names = set(members) & set(['mro']) + if invalid_names: + raise ValueError('Invalid enum member name(s): %s' % ( + ', '.join(invalid_names), )) + + # create our new Enum type + enum_class = super(EnumMeta, metacls).__new__(metacls, cls, bases, classdict) + enum_class._member_names_ = [] # names in random order + if OrderedDict is not None: + enum_class._member_map_ = OrderedDict() + else: + enum_class._member_map_ = {} # name->value map + enum_class._member_type_ = member_type + + # Reverse value->name map for hashable values. + enum_class._value2member_map_ = {} + + # instantiate them, checking for duplicates as we go + # we instantiate first instead of checking for duplicates first in case + # a custom __new__ is doing something funky with the values -- such as + # auto-numbering ;) + if __new__ is None: + __new__ = enum_class.__new__ + for member_name in __order__: + value = members[member_name] + if not isinstance(value, tuple): + args = (value, ) + else: + args = value + if member_type is tuple: # special case for tuple enums + args = (args, ) # wrap it one more time + if not use_args or not args: + enum_member = __new__(enum_class) + if not hasattr(enum_member, '_value_'): + enum_member._value_ = value + else: + enum_member = __new__(enum_class, *args) + if not hasattr(enum_member, '_value_'): + enum_member._value_ = member_type(*args) + value = enum_member._value_ + enum_member._name_ = member_name + enum_member.__objclass__ = enum_class + enum_member.__init__(*args) + # If another member with the same value was already defined, the + # new member becomes an alias to the existing one. + for name, canonical_member in enum_class._member_map_.items(): + if canonical_member.value == enum_member._value_: + enum_member = canonical_member + break + else: + # Aliases don't appear in member names (only in __members__). + enum_class._member_names_.append(member_name) + enum_class._member_map_[member_name] = enum_member + try: + # This may fail if value is not hashable. We can't add the value + # to the map, and by-value lookups for this value will be + # linear. + enum_class._value2member_map_[value] = enum_member + except TypeError: + pass + + + # If a custom type is mixed into the Enum, and it does not know how + # to pickle itself, pickle.dumps will succeed but pickle.loads will + # fail. Rather than have the error show up later and possibly far + # from the source, sabotage the pickle protocol for this class so + # that pickle.dumps also fails. + # + # However, if the new class implements its own __reduce_ex__, do not + # sabotage -- it's on them to make sure it works correctly. We use + # __reduce_ex__ instead of any of the others as it is preferred by + # pickle over __reduce__, and it handles all pickle protocols. + unpicklable = False + if '__reduce_ex__' not in classdict: + if member_type is not object: + methods = ('__getnewargs_ex__', '__getnewargs__', + '__reduce_ex__', '__reduce__') + if not any(m in member_type.__dict__ for m in methods): + _make_class_unpicklable(enum_class) + unpicklable = True + + + # double check that repr and friends are not the mixin's or various + # things break (such as pickle) + for name in ('__repr__', '__str__', '__format__', '__reduce_ex__'): + class_method = getattr(enum_class, name) + obj_method = getattr(member_type, name, None) + enum_method = getattr(first_enum, name, None) + if name not in classdict and class_method is not enum_method: + if name == '__reduce_ex__' and unpicklable: + continue + setattr(enum_class, name, enum_method) + + # method resolution and int's are not playing nice + # Python's less than 2.6 use __cmp__ + + if pyver < 2.6: + + if issubclass(enum_class, int): + setattr(enum_class, '__cmp__', getattr(int, '__cmp__')) + + elif pyver < 3.0: + + if issubclass(enum_class, int): + for method in ( + '__le__', + '__lt__', + '__gt__', + '__ge__', + '__eq__', + '__ne__', + '__hash__', + ): + setattr(enum_class, method, getattr(int, method)) + + # replace any other __new__ with our own (as long as Enum is not None, + # anyway) -- again, this is to support pickle + if Enum is not None: + # if the user defined their own __new__, save it before it gets + # clobbered in case they subclass later + if save_new: + setattr(enum_class, '__member_new__', enum_class.__dict__['__new__']) + setattr(enum_class, '__new__', Enum.__dict__['__new__']) + return enum_class + + def __call__(cls, value, names=None, module=None, type=None): + """Either returns an existing member, or creates a new enum class. + + This method is used both when an enum class is given a value to match + to an enumeration member (i.e. Color(3)) and for the functional API + (i.e. Color = Enum('Color', names='red green blue')). + + When used for the functional API: `module`, if set, will be stored in + the new class' __module__ attribute; `type`, if set, will be mixed in + as the first base class. + + Note: if `module` is not set this routine will attempt to discover the + calling module by walking the frame stack; if this is unsuccessful + the resulting class will not be pickleable. + + """ + if names is None: # simple value lookup + return cls.__new__(cls, value) + # otherwise, functional API: we're creating a new Enum type + return cls._create_(value, names, module=module, type=type) + + def __contains__(cls, member): + return isinstance(member, cls) and member.name in cls._member_map_ + + def __delattr__(cls, attr): + # nicer error message when someone tries to delete an attribute + # (see issue19025). + if attr in cls._member_map_: + raise AttributeError( + "%s: cannot delete Enum member." % cls.__name__) + super(EnumMeta, cls).__delattr__(attr) + + def __dir__(self): + return (['__class__', '__doc__', '__members__', '__module__'] + + self._member_names_) + + @property + def __members__(cls): + """Returns a mapping of member name->value. + + This mapping lists all enum members, including aliases. Note that this + is a copy of the internal mapping. + + """ + return cls._member_map_.copy() + + def __getattr__(cls, name): + """Return the enum member matching `name` + + We use __getattr__ instead of descriptors or inserting into the enum + class' __dict__ in order to support `name` and `value` being both + properties for enum members (which live in the class' __dict__) and + enum members themselves. + + """ + if _is_dunder(name): + raise AttributeError(name) + try: + return cls._member_map_[name] + except KeyError: + raise AttributeError(name) + + def __getitem__(cls, name): + return cls._member_map_[name] + + def __iter__(cls): + return (cls._member_map_[name] for name in cls._member_names_) + + def __reversed__(cls): + return (cls._member_map_[name] for name in reversed(cls._member_names_)) + + def __len__(cls): + return len(cls._member_names_) + + def __repr__(cls): + return "<enum %r>" % cls.__name__ + + def __setattr__(cls, name, value): + """Block attempts to reassign Enum members. + + A simple assignment to the class namespace only changes one of the + several possible ways to get an Enum member from the Enum class, + resulting in an inconsistent Enumeration. + + """ + member_map = cls.__dict__.get('_member_map_', {}) + if name in member_map: + raise AttributeError('Cannot reassign members.') + super(EnumMeta, cls).__setattr__(name, value) + + def _create_(cls, class_name, names=None, module=None, type=None): + """Convenience method to create a new Enum class. + + `names` can be: + + * A string containing member names, separated either with spaces or + commas. Values are auto-numbered from 1. + * An iterable of member names. Values are auto-numbered from 1. + * An iterable of (member name, value) pairs. + * A mapping of member name -> value. + + """ + if pyver < 3.0: + # if class_name is unicode, attempt a conversion to ASCII + if isinstance(class_name, unicode): + try: + class_name = class_name.encode('ascii') + except UnicodeEncodeError: + raise TypeError('%r is not representable in ASCII' % class_name) + metacls = cls.__class__ + if type is None: + bases = (cls, ) + else: + bases = (type, cls) + classdict = metacls.__prepare__(class_name, bases) + __order__ = [] + + # special processing needed for names? + if isinstance(names, basestring): + names = names.replace(',', ' ').split() + if isinstance(names, (tuple, list)) and isinstance(names[0], basestring): + names = [(e, i+1) for (i, e) in enumerate(names)] + + # Here, names is either an iterable of (name, value) or a mapping. + for item in names: + if isinstance(item, basestring): + member_name, member_value = item, names[item] + else: + member_name, member_value = item + classdict[member_name] = member_value + __order__.append(member_name) + # only set __order__ in classdict if name/value was not from a mapping + if not isinstance(item, basestring): + classdict['__order__'] = ' '.join(__order__) + enum_class = metacls.__new__(metacls, class_name, bases, classdict) + + # TODO: replace the frame hack if a blessed way to know the calling + # module is ever developed + if module is None: + try: + module = _sys._getframe(2).f_globals['__name__'] + except (AttributeError, ValueError): + pass + if module is None: + _make_class_unpicklable(enum_class) + else: + enum_class.__module__ = module + + return enum_class + + @staticmethod + def _get_mixins_(bases): + """Returns the type for creating enum members, and the first inherited + enum class. + + bases: the tuple of bases that was given to __new__ + + """ + if not bases or Enum is None: + return object, Enum + + + # double check that we are not subclassing a class with existing + # enumeration members; while we're at it, see if any other data + # type has been mixed in so we can use the correct __new__ + member_type = first_enum = None + for base in bases: + if (base is not Enum and + issubclass(base, Enum) and + base._member_names_): + raise TypeError("Cannot extend enumerations") + # base is now the last base in bases + if not issubclass(base, Enum): + raise TypeError("new enumerations must be created as " + "`ClassName([mixin_type,] enum_type)`") + + # get correct mix-in type (either mix-in type of Enum subclass, or + # first base if last base is Enum) + if not issubclass(bases[0], Enum): + member_type = bases[0] # first data type + first_enum = bases[-1] # enum type + else: + for base in bases[0].__mro__: + # most common: (IntEnum, int, Enum, object) + # possible: (<Enum 'AutoIntEnum'>, <Enum 'IntEnum'>, + # <class 'int'>, <Enum 'Enum'>, + # <class 'object'>) + if issubclass(base, Enum): + if first_enum is None: + first_enum = base + else: + if member_type is None: + member_type = base + + return member_type, first_enum + + if pyver < 3.0: + @staticmethod + def _find_new_(classdict, member_type, first_enum): + """Returns the __new__ to be used for creating the enum members. + + classdict: the class dictionary given to __new__ + member_type: the data type whose __new__ will be used by default + first_enum: enumeration to check for an overriding __new__ + + """ + # now find the correct __new__, checking to see of one was defined + # by the user; also check earlier enum classes in case a __new__ was + # saved as __member_new__ + __new__ = classdict.get('__new__', None) + if __new__: + return None, True, True # __new__, save_new, use_args + + N__new__ = getattr(None, '__new__') + O__new__ = getattr(object, '__new__') + if Enum is None: + E__new__ = N__new__ + else: + E__new__ = Enum.__dict__['__new__'] + # check all possibles for __member_new__ before falling back to + # __new__ + for method in ('__member_new__', '__new__'): + for possible in (member_type, first_enum): + try: + target = possible.__dict__[method] + except (AttributeError, KeyError): + target = getattr(possible, method, None) + if target not in [ + None, + N__new__, + O__new__, + E__new__, + ]: + if method == '__member_new__': + classdict['__new__'] = target + return None, False, True + if isinstance(target, staticmethod): + target = target.__get__(member_type) + __new__ = target + break + if __new__ is not None: + break + else: + __new__ = object.__new__ + + # if a non-object.__new__ is used then whatever value/tuple was + # assigned to the enum member name will be passed to __new__ and to the + # new enum member's __init__ + if __new__ is object.__new__: + use_args = False + else: + use_args = True + + return __new__, False, use_args + else: + @staticmethod + def _find_new_(classdict, member_type, first_enum): + """Returns the __new__ to be used for creating the enum members. + + classdict: the class dictionary given to __new__ + member_type: the data type whose __new__ will be used by default + first_enum: enumeration to check for an overriding __new__ + + """ + # now find the correct __new__, checking to see of one was defined + # by the user; also check earlier enum classes in case a __new__ was + # saved as __member_new__ + __new__ = classdict.get('__new__', None) + + # should __new__ be saved as __member_new__ later? + save_new = __new__ is not None + + if __new__ is None: + # check all possibles for __member_new__ before falling back to + # __new__ + for method in ('__member_new__', '__new__'): + for possible in (member_type, first_enum): + target = getattr(possible, method, None) + if target not in ( + None, + None.__new__, + object.__new__, + Enum.__new__, + ): + __new__ = target + break + if __new__ is not None: + break + else: + __new__ = object.__new__ + + # if a non-object.__new__ is used then whatever value/tuple was + # assigned to the enum member name will be passed to __new__ and to the + # new enum member's __init__ + if __new__ is object.__new__: + use_args = False + else: + use_args = True + + return __new__, save_new, use_args + + +######################################################## +# In order to support Python 2 and 3 with a single +# codebase we have to create the Enum methods separately +# and then use the `type(name, bases, dict)` method to +# create the class. +######################################################## +temp_enum_dict = {} +temp_enum_dict['__doc__'] = "Generic enumeration.\n\n Derive from this class to define new enumerations.\n\n" + +def __new__(cls, value): + # all enum instances are actually created during class construction + # without calling this method; this method is called by the metaclass' + # __call__ (i.e. Color(3) ), and by pickle + if type(value) is cls: + # For lookups like Color(Color.red) + value = value.value + #return value + # by-value search for a matching enum member + # see if it's in the reverse mapping (for hashable values) + try: + if value in cls._value2member_map_: + return cls._value2member_map_[value] + except TypeError: + # not there, now do long search -- O(n) behavior + for member in cls._member_map_.values(): + if member.value == value: + return member + raise ValueError("%s is not a valid %s" % (value, cls.__name__)) +temp_enum_dict['__new__'] = __new__ +del __new__ + +def __repr__(self): + return "<%s.%s: %r>" % ( + self.__class__.__name__, self._name_, self._value_) +temp_enum_dict['__repr__'] = __repr__ +del __repr__ + +def __str__(self): + return "%s.%s" % (self.__class__.__name__, self._name_) +temp_enum_dict['__str__'] = __str__ +del __str__ + +def __dir__(self): + added_behavior = [ + m + for cls in self.__class__.mro() + for m in cls.__dict__ + if m[0] != '_' + ] + return (['__class__', '__doc__', '__module__', ] + added_behavior) +temp_enum_dict['__dir__'] = __dir__ +del __dir__ + +def __format__(self, format_spec): + # mixed-in Enums should use the mixed-in type's __format__, otherwise + # we can get strange results with the Enum name showing up instead of + # the value + + # pure Enum branch + if self._member_type_ is object: + cls = str + val = str(self) + # mix-in branch + else: + cls = self._member_type_ + val = self.value + return cls.__format__(val, format_spec) +temp_enum_dict['__format__'] = __format__ +del __format__ + + +#################################### +# Python's less than 2.6 use __cmp__ + +if pyver < 2.6: + + def __cmp__(self, other): + if type(other) is self.__class__: + if self is other: + return 0 + return -1 + return NotImplemented + raise TypeError("unorderable types: %s() and %s()" % (self.__class__.__name__, other.__class__.__name__)) + temp_enum_dict['__cmp__'] = __cmp__ + del __cmp__ + +else: + + def __le__(self, other): + raise TypeError("unorderable types: %s() <= %s()" % (self.__class__.__name__, other.__class__.__name__)) + temp_enum_dict['__le__'] = __le__ + del __le__ + + def __lt__(self, other): + raise TypeError("unorderable types: %s() < %s()" % (self.__class__.__name__, other.__class__.__name__)) + temp_enum_dict['__lt__'] = __lt__ + del __lt__ + + def __ge__(self, other): + raise TypeError("unorderable types: %s() >= %s()" % (self.__class__.__name__, other.__class__.__name__)) + temp_enum_dict['__ge__'] = __ge__ + del __ge__ + + def __gt__(self, other): + raise TypeError("unorderable types: %s() > %s()" % (self.__class__.__name__, other.__class__.__name__)) + temp_enum_dict['__gt__'] = __gt__ + del __gt__ + + +def __eq__(self, other): + if type(other) is self.__class__: + return self is other + return NotImplemented +temp_enum_dict['__eq__'] = __eq__ +del __eq__ + +def __ne__(self, other): + if type(other) is self.__class__: + return self is not other + return NotImplemented +temp_enum_dict['__ne__'] = __ne__ +del __ne__ + +def __hash__(self): + return hash(self._name_) +temp_enum_dict['__hash__'] = __hash__ +del __hash__ + +def __reduce_ex__(self, proto): + return self.__class__, (self._value_, ) +temp_enum_dict['__reduce_ex__'] = __reduce_ex__ +del __reduce_ex__ + +# _RouteClassAttributeToGetattr is used to provide access to the `name` +# and `value` properties of enum members while keeping some measure of +# protection from modification, while still allowing for an enumeration +# to have members named `name` and `value`. This works because enumeration +# members are not set directly on the enum class -- __getattr__ is +# used to look them up. + +@_RouteClassAttributeToGetattr +def name(self): + return self._name_ +temp_enum_dict['name'] = name +del name + +@_RouteClassAttributeToGetattr +def value(self): + return self._value_ +temp_enum_dict['value'] = value +del value + +Enum = EnumMeta('Enum', (object, ), temp_enum_dict) +del temp_enum_dict + +# Enum has now been created +########################### + +class IntEnum(int, Enum): + """Enum where members are also (and must be) ints""" + + +def unique(enumeration): + """Class decorator that ensures only unique members exist in an enumeration.""" + duplicates = [] + for name, member in enumeration.__members__.items(): + if name != member.name: + duplicates.append((name, member.name)) + if duplicates: + duplicate_names = ', '.join( + ["%s -> %s" % (alias, name) for (alias, name) in duplicates] + ) + raise ValueError('duplicate names found in %r: %s' % + (enumeration, duplicate_names) + ) + return enumeration diff --git a/anknotes/enum/test_enum.py b/anknotes/enum/test_enum.py new file mode 100644 index 0000000..d7a9794 --- /dev/null +++ b/anknotes/enum/test_enum.py @@ -0,0 +1,1690 @@ +import enum +import sys +import unittest +from enum import Enum, IntEnum, unique, EnumMeta +from pickle import dumps, loads, PicklingError, HIGHEST_PROTOCOL + +pyver = float('%s.%s' % sys.version_info[:2]) + +try: + any +except NameError: + def any(iterable): + for element in iterable: + if element: + return True + return False + +try: + unicode +except NameError: + unicode = str + +try: + from collections import OrderedDict +except ImportError: + OrderedDict = None + +# for pickle tests +try: + class Stooges(Enum): + LARRY = 1 + CURLY = 2 + MOE = 3 +except Exception: + Stooges = sys.exc_info()[1] + +try: + class IntStooges(int, Enum): + LARRY = 1 + CURLY = 2 + MOE = 3 +except Exception: + IntStooges = sys.exc_info()[1] + +try: + class FloatStooges(float, Enum): + LARRY = 1.39 + CURLY = 2.72 + MOE = 3.142596 +except Exception: + FloatStooges = sys.exc_info()[1] + +# for pickle test and subclass tests +try: + class StrEnum(str, Enum): + 'accepts only string values' + class Name(StrEnum): + BDFL = 'Guido van Rossum' + FLUFL = 'Barry Warsaw' +except Exception: + Name = sys.exc_info()[1] + +try: + Question = Enum('Question', 'who what when where why', module=__name__) +except Exception: + Question = sys.exc_info()[1] + +try: + Answer = Enum('Answer', 'him this then there because') +except Exception: + Answer = sys.exc_info()[1] + +try: + Theory = Enum('Theory', 'rule law supposition', qualname='spanish_inquisition') +except Exception: + Theory = sys.exc_info()[1] + +# for doctests +try: + class Fruit(Enum): + tomato = 1 + banana = 2 + cherry = 3 +except Exception: + pass + +def test_pickle_dump_load(assertion, source, target=None, + protocol=(0, HIGHEST_PROTOCOL)): + start, stop = protocol + failures = [] + for protocol in range(start, stop+1): + try: + if target is None: + assertion(loads(dumps(source, protocol=protocol)) is source) + else: + assertion(loads(dumps(source, protocol=protocol)), target) + except Exception: + exc, tb = sys.exc_info()[1:] + failures.append('%2d: %s' %(protocol, exc)) + if failures: + raise ValueError('Failed with protocols: %s' % ', '.join(failures)) + +def test_pickle_exception(assertion, exception, obj, + protocol=(0, HIGHEST_PROTOCOL)): + start, stop = protocol + failures = [] + for protocol in range(start, stop+1): + try: + assertion(exception, dumps, obj, protocol=protocol) + except Exception: + exc = sys.exc_info()[1] + failures.append('%d: %s %s' % (protocol, exc.__class__.__name__, exc)) + if failures: + raise ValueError('Failed with protocols: %s' % ', '.join(failures)) + + +class TestHelpers(unittest.TestCase): + # _is_descriptor, _is_sunder, _is_dunder + + def test_is_descriptor(self): + class foo: + pass + for attr in ('__get__','__set__','__delete__'): + obj = foo() + self.assertFalse(enum._is_descriptor(obj)) + setattr(obj, attr, 1) + self.assertTrue(enum._is_descriptor(obj)) + + def test_is_sunder(self): + for s in ('_a_', '_aa_'): + self.assertTrue(enum._is_sunder(s)) + + for s in ('a', 'a_', '_a', '__a', 'a__', '__a__', '_a__', '__a_', '_', + '__', '___', '____', '_____',): + self.assertFalse(enum._is_sunder(s)) + + def test_is_dunder(self): + for s in ('__a__', '__aa__'): + self.assertTrue(enum._is_dunder(s)) + for s in ('a', 'a_', '_a', '__a', 'a__', '_a_', '_a__', '__a_', '_', + '__', '___', '____', '_____',): + self.assertFalse(enum._is_dunder(s)) + + +class TestEnum(unittest.TestCase): + def setUp(self): + class Season(Enum): + SPRING = 1 + SUMMER = 2 + AUTUMN = 3 + WINTER = 4 + self.Season = Season + + class Konstants(float, Enum): + E = 2.7182818 + PI = 3.1415926 + TAU = 2 * PI + self.Konstants = Konstants + + class Grades(IntEnum): + A = 5 + B = 4 + C = 3 + D = 2 + F = 0 + self.Grades = Grades + + class Directional(str, Enum): + EAST = 'east' + WEST = 'west' + NORTH = 'north' + SOUTH = 'south' + self.Directional = Directional + + from datetime import date + class Holiday(date, Enum): + NEW_YEAR = 2013, 1, 1 + IDES_OF_MARCH = 2013, 3, 15 + self.Holiday = Holiday + + if pyver >= 2.6: # cannot specify custom `dir` on previous versions + def test_dir_on_class(self): + Season = self.Season + self.assertEqual( + set(dir(Season)), + set(['__class__', '__doc__', '__members__', '__module__', + 'SPRING', 'SUMMER', 'AUTUMN', 'WINTER']), + ) + + def test_dir_on_item(self): + Season = self.Season + self.assertEqual( + set(dir(Season.WINTER)), + set(['__class__', '__doc__', '__module__', 'name', 'value']), + ) + + def test_dir_on_sub_with_behavior_on_super(self): + # see issue22506 + class SuperEnum(Enum): + def invisible(self): + return "did you see me?" + class SubEnum(SuperEnum): + sample = 5 + self.assertEqual( + set(dir(SubEnum.sample)), + set(['__class__', '__doc__', '__module__', 'name', 'value', 'invisible']), + ) + + if pyver >= 2.7: # OrderedDict first available here + def test_members_is_ordereddict_if_ordered(self): + class Ordered(Enum): + __order__ = 'first second third' + first = 'bippity' + second = 'boppity' + third = 'boo' + self.assertTrue(type(Ordered.__members__) is OrderedDict) + + def test_members_is_ordereddict_if_not_ordered(self): + class Unordered(Enum): + this = 'that' + these = 'those' + self.assertTrue(type(Unordered.__members__) is OrderedDict) + + if pyver >= 3.0: # all objects are ordered in Python 2.x + def test_members_is_always_ordered(self): + class AlwaysOrdered(Enum): + first = 1 + second = 2 + third = 3 + self.assertTrue(type(AlwaysOrdered.__members__) is OrderedDict) + + def test_comparisons(self): + def bad_compare(): + Season.SPRING > 4 + Season = self.Season + self.assertNotEqual(Season.SPRING, 1) + self.assertRaises(TypeError, bad_compare) + + class Part(Enum): + SPRING = 1 + CLIP = 2 + BARREL = 3 + + self.assertNotEqual(Season.SPRING, Part.SPRING) + def bad_compare(): + Season.SPRING < Part.CLIP + self.assertRaises(TypeError, bad_compare) + + def test_enum_in_enum_out(self): + Season = self.Season + self.assertTrue(Season(Season.WINTER) is Season.WINTER) + + def test_enum_value(self): + Season = self.Season + self.assertEqual(Season.SPRING.value, 1) + + def test_intenum_value(self): + self.assertEqual(IntStooges.CURLY.value, 2) + + def test_enum(self): + Season = self.Season + lst = list(Season) + self.assertEqual(len(lst), len(Season)) + self.assertEqual(len(Season), 4, Season) + self.assertEqual( + [Season.SPRING, Season.SUMMER, Season.AUTUMN, Season.WINTER], lst) + + for i, season in enumerate('SPRING SUMMER AUTUMN WINTER'.split()): + i += 1 + e = Season(i) + self.assertEqual(e, getattr(Season, season)) + self.assertEqual(e.value, i) + self.assertNotEqual(e, i) + self.assertEqual(e.name, season) + self.assertTrue(e in Season) + self.assertTrue(type(e) is Season) + self.assertTrue(isinstance(e, Season)) + self.assertEqual(str(e), 'Season.' + season) + self.assertEqual( + repr(e), + '<Season.%s: %s>' % (season, i), + ) + + def test_value_name(self): + Season = self.Season + self.assertEqual(Season.SPRING.name, 'SPRING') + self.assertEqual(Season.SPRING.value, 1) + def set_name(obj, new_value): + obj.name = new_value + def set_value(obj, new_value): + obj.value = new_value + self.assertRaises(AttributeError, set_name, Season.SPRING, 'invierno', ) + self.assertRaises(AttributeError, set_value, Season.SPRING, 2) + + def test_attribute_deletion(self): + class Season(Enum): + SPRING = 1 + SUMMER = 2 + AUTUMN = 3 + WINTER = 4 + + def spam(cls): + pass + + self.assertTrue(hasattr(Season, 'spam')) + del Season.spam + self.assertFalse(hasattr(Season, 'spam')) + + self.assertRaises(AttributeError, delattr, Season, 'SPRING') + self.assertRaises(AttributeError, delattr, Season, 'DRY') + self.assertRaises(AttributeError, delattr, Season.SPRING, 'name') + + def test_invalid_names(self): + def create_bad_class_1(): + class Wrong(Enum): + mro = 9 + def create_bad_class_2(): + class Wrong(Enum): + _reserved_ = 3 + self.assertRaises(ValueError, create_bad_class_1) + self.assertRaises(ValueError, create_bad_class_2) + + def test_contains(self): + Season = self.Season + self.assertTrue(Season.AUTUMN in Season) + self.assertTrue(3 not in Season) + + val = Season(3) + self.assertTrue(val in Season) + + class OtherEnum(Enum): + one = 1; two = 2 + self.assertTrue(OtherEnum.two not in Season) + + if pyver >= 2.6: # when `format` came into being + + def test_format_enum(self): + Season = self.Season + self.assertEqual('{0}'.format(Season.SPRING), + '{0}'.format(str(Season.SPRING))) + self.assertEqual( '{0:}'.format(Season.SPRING), + '{0:}'.format(str(Season.SPRING))) + self.assertEqual('{0:20}'.format(Season.SPRING), + '{0:20}'.format(str(Season.SPRING))) + self.assertEqual('{0:^20}'.format(Season.SPRING), + '{0:^20}'.format(str(Season.SPRING))) + self.assertEqual('{0:>20}'.format(Season.SPRING), + '{0:>20}'.format(str(Season.SPRING))) + self.assertEqual('{0:<20}'.format(Season.SPRING), + '{0:<20}'.format(str(Season.SPRING))) + + def test_format_enum_custom(self): + class TestFloat(float, Enum): + one = 1.0 + two = 2.0 + def __format__(self, spec): + return 'TestFloat success!' + self.assertEqual('{0}'.format(TestFloat.one), 'TestFloat success!') + + def assertFormatIsValue(self, spec, member): + self.assertEqual(spec.format(member), spec.format(member.value)) + + def test_format_enum_date(self): + Holiday = self.Holiday + self.assertFormatIsValue('{0}', Holiday.IDES_OF_MARCH) + self.assertFormatIsValue('{0:}', Holiday.IDES_OF_MARCH) + self.assertFormatIsValue('{0:20}', Holiday.IDES_OF_MARCH) + self.assertFormatIsValue('{0:^20}', Holiday.IDES_OF_MARCH) + self.assertFormatIsValue('{0:>20}', Holiday.IDES_OF_MARCH) + self.assertFormatIsValue('{0:<20}', Holiday.IDES_OF_MARCH) + self.assertFormatIsValue('{0:%Y %m}', Holiday.IDES_OF_MARCH) + self.assertFormatIsValue('{0:%Y %m %M:00}', Holiday.IDES_OF_MARCH) + + def test_format_enum_float(self): + Konstants = self.Konstants + self.assertFormatIsValue('{0}', Konstants.TAU) + self.assertFormatIsValue('{0:}', Konstants.TAU) + self.assertFormatIsValue('{0:20}', Konstants.TAU) + self.assertFormatIsValue('{0:^20}', Konstants.TAU) + self.assertFormatIsValue('{0:>20}', Konstants.TAU) + self.assertFormatIsValue('{0:<20}', Konstants.TAU) + self.assertFormatIsValue('{0:n}', Konstants.TAU) + self.assertFormatIsValue('{0:5.2}', Konstants.TAU) + self.assertFormatIsValue('{0:f}', Konstants.TAU) + + def test_format_enum_int(self): + Grades = self.Grades + self.assertFormatIsValue('{0}', Grades.C) + self.assertFormatIsValue('{0:}', Grades.C) + self.assertFormatIsValue('{0:20}', Grades.C) + self.assertFormatIsValue('{0:^20}', Grades.C) + self.assertFormatIsValue('{0:>20}', Grades.C) + self.assertFormatIsValue('{0:<20}', Grades.C) + self.assertFormatIsValue('{0:+}', Grades.C) + self.assertFormatIsValue('{0:08X}', Grades.C) + self.assertFormatIsValue('{0:b}', Grades.C) + + def test_format_enum_str(self): + Directional = self.Directional + self.assertFormatIsValue('{0}', Directional.WEST) + self.assertFormatIsValue('{0:}', Directional.WEST) + self.assertFormatIsValue('{0:20}', Directional.WEST) + self.assertFormatIsValue('{0:^20}', Directional.WEST) + self.assertFormatIsValue('{0:>20}', Directional.WEST) + self.assertFormatIsValue('{0:<20}', Directional.WEST) + + def test_hash(self): + Season = self.Season + dates = {} + dates[Season.WINTER] = '1225' + dates[Season.SPRING] = '0315' + dates[Season.SUMMER] = '0704' + dates[Season.AUTUMN] = '1031' + self.assertEqual(dates[Season.AUTUMN], '1031') + + def test_enum_duplicates(self): + __order__ = "SPRING SUMMER AUTUMN WINTER" + class Season(Enum): + SPRING = 1 + SUMMER = 2 + AUTUMN = FALL = 3 + WINTER = 4 + ANOTHER_SPRING = 1 + lst = list(Season) + self.assertEqual( + lst, + [Season.SPRING, Season.SUMMER, + Season.AUTUMN, Season.WINTER, + ]) + self.assertTrue(Season.FALL is Season.AUTUMN) + self.assertEqual(Season.FALL.value, 3) + self.assertEqual(Season.AUTUMN.value, 3) + self.assertTrue(Season(3) is Season.AUTUMN) + self.assertTrue(Season(1) is Season.SPRING) + self.assertEqual(Season.FALL.name, 'AUTUMN') + self.assertEqual( + set([k for k,v in Season.__members__.items() if v.name != k]), + set(['FALL', 'ANOTHER_SPRING']), + ) + + if pyver >= 3.0: + cls = vars() + result = {'Enum':Enum} + exec("""def test_duplicate_name(self): + with self.assertRaises(TypeError): + class Color(Enum): + red = 1 + green = 2 + blue = 3 + red = 4 + + with self.assertRaises(TypeError): + class Color(Enum): + red = 1 + green = 2 + blue = 3 + def red(self): + return 'red' + + with self.assertRaises(TypeError): + class Color(Enum): + @property + + def red(self): + return 'redder' + red = 1 + green = 2 + blue = 3""", + result) + cls['test_duplicate_name'] = result['test_duplicate_name'] + + def test_enum_with_value_name(self): + class Huh(Enum): + name = 1 + value = 2 + self.assertEqual( + list(Huh), + [Huh.name, Huh.value], + ) + self.assertTrue(type(Huh.name) is Huh) + self.assertEqual(Huh.name.name, 'name') + self.assertEqual(Huh.name.value, 1) + + def test_intenum_from_scratch(self): + class phy(int, Enum): + pi = 3 + tau = 2 * pi + self.assertTrue(phy.pi < phy.tau) + + def test_intenum_inherited(self): + class IntEnum(int, Enum): + pass + class phy(IntEnum): + pi = 3 + tau = 2 * pi + self.assertTrue(phy.pi < phy.tau) + + def test_floatenum_from_scratch(self): + class phy(float, Enum): + pi = 3.1415926 + tau = 2 * pi + self.assertTrue(phy.pi < phy.tau) + + def test_floatenum_inherited(self): + class FloatEnum(float, Enum): + pass + class phy(FloatEnum): + pi = 3.1415926 + tau = 2 * pi + self.assertTrue(phy.pi < phy.tau) + + def test_strenum_from_scratch(self): + class phy(str, Enum): + pi = 'Pi' + tau = 'Tau' + self.assertTrue(phy.pi < phy.tau) + + def test_strenum_inherited(self): + class StrEnum(str, Enum): + pass + class phy(StrEnum): + pi = 'Pi' + tau = 'Tau' + self.assertTrue(phy.pi < phy.tau) + + def test_intenum(self): + class WeekDay(IntEnum): + SUNDAY = 1 + MONDAY = 2 + TUESDAY = 3 + WEDNESDAY = 4 + THURSDAY = 5 + FRIDAY = 6 + SATURDAY = 7 + + self.assertEqual(['a', 'b', 'c'][WeekDay.MONDAY], 'c') + self.assertEqual([i for i in range(WeekDay.TUESDAY)], [0, 1, 2]) + + lst = list(WeekDay) + self.assertEqual(len(lst), len(WeekDay)) + self.assertEqual(len(WeekDay), 7) + target = 'SUNDAY MONDAY TUESDAY WEDNESDAY THURSDAY FRIDAY SATURDAY' + target = target.split() + for i, weekday in enumerate(target): + i += 1 + e = WeekDay(i) + self.assertEqual(e, i) + self.assertEqual(int(e), i) + self.assertEqual(e.name, weekday) + self.assertTrue(e in WeekDay) + self.assertEqual(lst.index(e)+1, i) + self.assertTrue(0 < e < 8) + self.assertTrue(type(e) is WeekDay) + self.assertTrue(isinstance(e, int)) + self.assertTrue(isinstance(e, Enum)) + + def test_intenum_duplicates(self): + class WeekDay(IntEnum): + __order__ = 'SUNDAY MONDAY TUESDAY WEDNESDAY THURSDAY FRIDAY SATURDAY' + SUNDAY = 1 + MONDAY = 2 + TUESDAY = TEUSDAY = 3 + WEDNESDAY = 4 + THURSDAY = 5 + FRIDAY = 6 + SATURDAY = 7 + self.assertTrue(WeekDay.TEUSDAY is WeekDay.TUESDAY) + self.assertEqual(WeekDay(3).name, 'TUESDAY') + self.assertEqual([k for k,v in WeekDay.__members__.items() + if v.name != k], ['TEUSDAY', ]) + + def test_pickle_enum(self): + if isinstance(Stooges, Exception): + raise Stooges + test_pickle_dump_load(self.assertTrue, Stooges.CURLY) + test_pickle_dump_load(self.assertTrue, Stooges) + + def test_pickle_int(self): + if isinstance(IntStooges, Exception): + raise IntStooges + test_pickle_dump_load(self.assertTrue, IntStooges.CURLY) + test_pickle_dump_load(self.assertTrue, IntStooges) + + def test_pickle_float(self): + if isinstance(FloatStooges, Exception): + raise FloatStooges + test_pickle_dump_load(self.assertTrue, FloatStooges.CURLY) + test_pickle_dump_load(self.assertTrue, FloatStooges) + + def test_pickle_enum_function(self): + if isinstance(Answer, Exception): + raise Answer + test_pickle_dump_load(self.assertTrue, Answer.him) + test_pickle_dump_load(self.assertTrue, Answer) + + def test_pickle_enum_function_with_module(self): + if isinstance(Question, Exception): + raise Question + test_pickle_dump_load(self.assertTrue, Question.who) + test_pickle_dump_load(self.assertTrue, Question) + + if pyver >= 3.4: + def test_class_nested_enum_and_pickle_protocol_four(self): + # would normally just have this directly in the class namespace + class NestedEnum(Enum): + twigs = 'common' + shiny = 'rare' + + self.__class__.NestedEnum = NestedEnum + self.NestedEnum.__qualname__ = '%s.NestedEnum' % self.__class__.__name__ + test_pickle_exception( + self.assertRaises, PicklingError, self.NestedEnum.twigs, + protocol=(0, 3)) + test_pickle_dump_load(self.assertTrue, self.NestedEnum.twigs, + protocol=(4, HIGHEST_PROTOCOL)) + + def test_exploding_pickle(self): + BadPickle = Enum('BadPickle', 'dill sweet bread-n-butter') + enum._make_class_unpicklable(BadPickle) + globals()['BadPickle'] = BadPickle + test_pickle_exception(self.assertRaises, TypeError, BadPickle.dill) + test_pickle_exception(self.assertRaises, PicklingError, BadPickle) + + def test_string_enum(self): + class SkillLevel(str, Enum): + master = 'what is the sound of one hand clapping?' + journeyman = 'why did the chicken cross the road?' + apprentice = 'knock, knock!' + self.assertEqual(SkillLevel.apprentice, 'knock, knock!') + + def test_getattr_getitem(self): + class Period(Enum): + morning = 1 + noon = 2 + evening = 3 + night = 4 + self.assertTrue(Period(2) is Period.noon) + self.assertTrue(getattr(Period, 'night') is Period.night) + self.assertTrue(Period['morning'] is Period.morning) + + def test_getattr_dunder(self): + Season = self.Season + self.assertTrue(getattr(Season, '__hash__')) + + def test_iteration_order(self): + class Season(Enum): + __order__ = 'SUMMER WINTER AUTUMN SPRING' + SUMMER = 2 + WINTER = 4 + AUTUMN = 3 + SPRING = 1 + self.assertEqual( + list(Season), + [Season.SUMMER, Season.WINTER, Season.AUTUMN, Season.SPRING], + ) + + def test_iteration_order_with_unorderable_values(self): + class Complex(Enum): + a = complex(7, 9) + b = complex(3.14, 2) + c = complex(1, -1) + d = complex(-77, 32) + self.assertEqual( + list(Complex), + [Complex.a, Complex.b, Complex.c, Complex.d], + ) + + def test_programatic_function_string(self): + SummerMonth = Enum('SummerMonth', 'june july august') + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate('june july august'.split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(int(e.value), i) + self.assertNotEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programatic_function_string_list(self): + SummerMonth = Enum('SummerMonth', ['june', 'july', 'august']) + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate('june july august'.split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(int(e.value), i) + self.assertNotEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programatic_function_iterable(self): + SummerMonth = Enum( + 'SummerMonth', + (('june', 1), ('july', 2), ('august', 3)) + ) + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate('june july august'.split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(int(e.value), i) + self.assertNotEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programatic_function_from_dict(self): + SummerMonth = Enum( + 'SummerMonth', + dict((('june', 1), ('july', 2), ('august', 3))) + ) + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + if pyver < 3.0: + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate('june july august'.split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(int(e.value), i) + self.assertNotEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programatic_function_type(self): + SummerMonth = Enum('SummerMonth', 'june july august', type=int) + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate('june july august'.split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programatic_function_type_from_subclass(self): + SummerMonth = IntEnum('SummerMonth', 'june july august') + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate('june july august'.split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programatic_function_unicode(self): + SummerMonth = Enum('SummerMonth', unicode('june july august')) + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate(unicode('june july august').split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(int(e.value), i) + self.assertNotEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programatic_function_unicode_list(self): + SummerMonth = Enum('SummerMonth', [unicode('june'), unicode('july'), unicode('august')]) + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate(unicode('june july august').split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(int(e.value), i) + self.assertNotEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programatic_function_unicode_iterable(self): + SummerMonth = Enum( + 'SummerMonth', + ((unicode('june'), 1), (unicode('july'), 2), (unicode('august'), 3)) + ) + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate(unicode('june july august').split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(int(e.value), i) + self.assertNotEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programatic_function_from_unicode_dict(self): + SummerMonth = Enum( + 'SummerMonth', + dict(((unicode('june'), 1), (unicode('july'), 2), (unicode('august'), 3))) + ) + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + if pyver < 3.0: + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate(unicode('june july august').split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(int(e.value), i) + self.assertNotEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programatic_function_unicode_type(self): + SummerMonth = Enum('SummerMonth', unicode('june july august'), type=int) + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate(unicode('june july august').split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programatic_function_unicode_type_from_subclass(self): + SummerMonth = IntEnum('SummerMonth', unicode('june july august')) + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate(unicode('june july august').split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(e, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_programmatic_function_unicode_class(self): + if pyver < 3.0: + class_names = unicode('SummerMonth'), 'S\xfcmm\xe9rM\xf6nth'.decode('latin1') + else: + class_names = 'SummerMonth', 'S\xfcmm\xe9rM\xf6nth' + for i, class_name in enumerate(class_names): + if pyver < 3.0 and i == 1: + self.assertRaises(TypeError, Enum, class_name, unicode('june july august')) + else: + SummerMonth = Enum(class_name, unicode('june july august')) + lst = list(SummerMonth) + self.assertEqual(len(lst), len(SummerMonth)) + self.assertEqual(len(SummerMonth), 3, SummerMonth) + self.assertEqual( + [SummerMonth.june, SummerMonth.july, SummerMonth.august], + lst, + ) + for i, month in enumerate(unicode('june july august').split()): + i += 1 + e = SummerMonth(i) + self.assertEqual(e.value, i) + self.assertEqual(e.name, month) + self.assertTrue(e in SummerMonth) + self.assertTrue(type(e) is SummerMonth) + + def test_subclassing(self): + if isinstance(Name, Exception): + raise Name + self.assertEqual(Name.BDFL, 'Guido van Rossum') + self.assertTrue(Name.BDFL, Name('Guido van Rossum')) + self.assertTrue(Name.BDFL is getattr(Name, 'BDFL')) + test_pickle_dump_load(self.assertTrue, Name.BDFL) + + def test_extending(self): + def bad_extension(): + class Color(Enum): + red = 1 + green = 2 + blue = 3 + class MoreColor(Color): + cyan = 4 + magenta = 5 + yellow = 6 + self.assertRaises(TypeError, bad_extension) + + def test_exclude_methods(self): + class whatever(Enum): + this = 'that' + these = 'those' + def really(self): + return 'no, not %s' % self.value + self.assertFalse(type(whatever.really) is whatever) + self.assertEqual(whatever.this.really(), 'no, not that') + + def test_wrong_inheritance_order(self): + def wrong_inherit(): + class Wrong(Enum, str): + NotHere = 'error before this point' + self.assertRaises(TypeError, wrong_inherit) + + def test_intenum_transitivity(self): + class number(IntEnum): + one = 1 + two = 2 + three = 3 + class numero(IntEnum): + uno = 1 + dos = 2 + tres = 3 + self.assertEqual(number.one, numero.uno) + self.assertEqual(number.two, numero.dos) + self.assertEqual(number.three, numero.tres) + + def test_introspection(self): + class Number(IntEnum): + one = 100 + two = 200 + self.assertTrue(Number.one._member_type_ is int) + self.assertTrue(Number._member_type_ is int) + class String(str, Enum): + yarn = 'soft' + rope = 'rough' + wire = 'hard' + self.assertTrue(String.yarn._member_type_ is str) + self.assertTrue(String._member_type_ is str) + class Plain(Enum): + vanilla = 'white' + one = 1 + self.assertTrue(Plain.vanilla._member_type_ is object) + self.assertTrue(Plain._member_type_ is object) + + def test_wrong_enum_in_call(self): + class Monochrome(Enum): + black = 0 + white = 1 + class Gender(Enum): + male = 0 + female = 1 + self.assertRaises(ValueError, Monochrome, Gender.male) + + def test_wrong_enum_in_mixed_call(self): + class Monochrome(IntEnum): + black = 0 + white = 1 + class Gender(Enum): + male = 0 + female = 1 + self.assertRaises(ValueError, Monochrome, Gender.male) + + def test_mixed_enum_in_call_1(self): + class Monochrome(IntEnum): + black = 0 + white = 1 + class Gender(IntEnum): + male = 0 + female = 1 + self.assertTrue(Monochrome(Gender.female) is Monochrome.white) + + def test_mixed_enum_in_call_2(self): + class Monochrome(Enum): + black = 0 + white = 1 + class Gender(IntEnum): + male = 0 + female = 1 + self.assertTrue(Monochrome(Gender.male) is Monochrome.black) + + def test_flufl_enum(self): + class Fluflnum(Enum): + def __int__(self): + return int(self.value) + class MailManOptions(Fluflnum): + option1 = 1 + option2 = 2 + option3 = 3 + self.assertEqual(int(MailManOptions.option1), 1) + + def test_no_such_enum_member(self): + class Color(Enum): + red = 1 + green = 2 + blue = 3 + self.assertRaises(ValueError, Color, 4) + self.assertRaises(KeyError, Color.__getitem__, 'chartreuse') + + def test_new_repr(self): + class Color(Enum): + red = 1 + green = 2 + blue = 3 + def __repr__(self): + return "don't you just love shades of %s?" % self.name + self.assertEqual( + repr(Color.blue), + "don't you just love shades of blue?", + ) + + def test_inherited_repr(self): + class MyEnum(Enum): + def __repr__(self): + return "My name is %s." % self.name + class MyIntEnum(int, MyEnum): + this = 1 + that = 2 + theother = 3 + self.assertEqual(repr(MyIntEnum.that), "My name is that.") + + def test_multiple_mixin_mro(self): + class auto_enum(EnumMeta): + def __new__(metacls, cls, bases, classdict): + original_dict = classdict + classdict = enum._EnumDict() + for k, v in original_dict.items(): + classdict[k] = v + temp = type(classdict)() + names = set(classdict._member_names) + i = 0 + for k in classdict._member_names: + v = classdict[k] + if v == (): + v = i + else: + i = v + i += 1 + temp[k] = v + for k, v in classdict.items(): + if k not in names: + temp[k] = v + return super(auto_enum, metacls).__new__( + metacls, cls, bases, temp) + + AutoNumberedEnum = auto_enum('AutoNumberedEnum', (Enum,), {}) + + AutoIntEnum = auto_enum('AutoIntEnum', (IntEnum,), {}) + + class TestAutoNumber(AutoNumberedEnum): + a = () + b = 3 + c = () + + class TestAutoInt(AutoIntEnum): + a = () + b = 3 + c = () + + def test_subclasses_with_getnewargs(self): + class NamedInt(int): + __qualname__ = 'NamedInt' # needed for pickle protocol 4 + def __new__(cls, *args): + _args = args + if len(args) < 1: + raise TypeError("name and value must be specified") + name, args = args[0], args[1:] + self = int.__new__(cls, *args) + self._intname = name + self._args = _args + return self + def __getnewargs__(self): + return self._args + @property + def __name__(self): + return self._intname + def __repr__(self): + # repr() is updated to include the name and type info + return "%s(%r, %s)" % (type(self).__name__, + self.__name__, + int.__repr__(self)) + def __str__(self): + # str() is unchanged, even if it relies on the repr() fallback + base = int + base_str = base.__str__ + if base_str.__objclass__ is object: + return base.__repr__(self) + return base_str(self) + # for simplicity, we only define one operator that + # propagates expressions + def __add__(self, other): + temp = int(self) + int( other) + if isinstance(self, NamedInt) and isinstance(other, NamedInt): + return NamedInt( + '(%s + %s)' % (self.__name__, other.__name__), + temp ) + else: + return temp + + class NEI(NamedInt, Enum): + __qualname__ = 'NEI' # needed for pickle protocol 4 + x = ('the-x', 1) + y = ('the-y', 2) + + self.assertTrue(NEI.__new__ is Enum.__new__) + self.assertEqual(repr(NEI.x + NEI.y), "NamedInt('(the-x + the-y)', 3)") + globals()['NamedInt'] = NamedInt + globals()['NEI'] = NEI + NI5 = NamedInt('test', 5) + self.assertEqual(NI5, 5) + test_pickle_dump_load(self.assertTrue, NI5, 5) + self.assertEqual(NEI.y.value, 2) + test_pickle_dump_load(self.assertTrue, NEI.y) + + if pyver >= 3.4: + def test_subclasses_with_getnewargs_ex(self): + class NamedInt(int): + __qualname__ = 'NamedInt' # needed for pickle protocol 4 + def __new__(cls, *args): + _args = args + if len(args) < 2: + raise TypeError("name and value must be specified") + name, args = args[0], args[1:] + self = int.__new__(cls, *args) + self._intname = name + self._args = _args + return self + def __getnewargs_ex__(self): + return self._args, {} + @property + def __name__(self): + return self._intname + def __repr__(self): + # repr() is updated to include the name and type info + return "{}({!r}, {})".format(type(self).__name__, + self.__name__, + int.__repr__(self)) + def __str__(self): + # str() is unchanged, even if it relies on the repr() fallback + base = int + base_str = base.__str__ + if base_str.__objclass__ is object: + return base.__repr__(self) + return base_str(self) + # for simplicity, we only define one operator that + # propagates expressions + def __add__(self, other): + temp = int(self) + int( other) + if isinstance(self, NamedInt) and isinstance(other, NamedInt): + return NamedInt( + '({0} + {1})'.format(self.__name__, other.__name__), + temp ) + else: + return temp + + class NEI(NamedInt, Enum): + __qualname__ = 'NEI' # needed for pickle protocol 4 + x = ('the-x', 1) + y = ('the-y', 2) + + + self.assertIs(NEI.__new__, Enum.__new__) + self.assertEqual(repr(NEI.x + NEI.y), "NamedInt('(the-x + the-y)', 3)") + globals()['NamedInt'] = NamedInt + globals()['NEI'] = NEI + NI5 = NamedInt('test', 5) + self.assertEqual(NI5, 5) + test_pickle_dump_load(self.assertEqual, NI5, 5, protocol=(4, HIGHEST_PROTOCOL)) + self.assertEqual(NEI.y.value, 2) + test_pickle_dump_load(self.assertTrue, NEI.y, protocol=(4, HIGHEST_PROTOCOL)) + + def test_subclasses_with_reduce(self): + class NamedInt(int): + __qualname__ = 'NamedInt' # needed for pickle protocol 4 + def __new__(cls, *args): + _args = args + if len(args) < 1: + raise TypeError("name and value must be specified") + name, args = args[0], args[1:] + self = int.__new__(cls, *args) + self._intname = name + self._args = _args + return self + def __reduce__(self): + return self.__class__, self._args + @property + def __name__(self): + return self._intname + def __repr__(self): + # repr() is updated to include the name and type info + return "%s(%r, %s)" % (type(self).__name__, + self.__name__, + int.__repr__(self)) + def __str__(self): + # str() is unchanged, even if it relies on the repr() fallback + base = int + base_str = base.__str__ + if base_str.__objclass__ is object: + return base.__repr__(self) + return base_str(self) + # for simplicity, we only define one operator that + # propagates expressions + def __add__(self, other): + temp = int(self) + int( other) + if isinstance(self, NamedInt) and isinstance(other, NamedInt): + return NamedInt( + '(%s + %s)' % (self.__name__, other.__name__), + temp ) + else: + return temp + + class NEI(NamedInt, Enum): + __qualname__ = 'NEI' # needed for pickle protocol 4 + x = ('the-x', 1) + y = ('the-y', 2) + + + self.assertTrue(NEI.__new__ is Enum.__new__) + self.assertEqual(repr(NEI.x + NEI.y), "NamedInt('(the-x + the-y)', 3)") + globals()['NamedInt'] = NamedInt + globals()['NEI'] = NEI + NI5 = NamedInt('test', 5) + self.assertEqual(NI5, 5) + test_pickle_dump_load(self.assertEqual, NI5, 5) + self.assertEqual(NEI.y.value, 2) + test_pickle_dump_load(self.assertTrue, NEI.y) + + def test_subclasses_with_reduce_ex(self): + class NamedInt(int): + __qualname__ = 'NamedInt' # needed for pickle protocol 4 + def __new__(cls, *args): + _args = args + if len(args) < 1: + raise TypeError("name and value must be specified") + name, args = args[0], args[1:] + self = int.__new__(cls, *args) + self._intname = name + self._args = _args + return self + def __reduce_ex__(self, proto): + return self.__class__, self._args + @property + def __name__(self): + return self._intname + def __repr__(self): + # repr() is updated to include the name and type info + return "%s(%r, %s)" % (type(self).__name__, + self.__name__, + int.__repr__(self)) + def __str__(self): + # str() is unchanged, even if it relies on the repr() fallback + base = int + base_str = base.__str__ + if base_str.__objclass__ is object: + return base.__repr__(self) + return base_str(self) + # for simplicity, we only define one operator that + # propagates expressions + def __add__(self, other): + temp = int(self) + int( other) + if isinstance(self, NamedInt) and isinstance(other, NamedInt): + return NamedInt( + '(%s + %s)' % (self.__name__, other.__name__), + temp ) + else: + return temp + + class NEI(NamedInt, Enum): + __qualname__ = 'NEI' # needed for pickle protocol 4 + x = ('the-x', 1) + y = ('the-y', 2) + + + self.assertTrue(NEI.__new__ is Enum.__new__) + self.assertEqual(repr(NEI.x + NEI.y), "NamedInt('(the-x + the-y)', 3)") + globals()['NamedInt'] = NamedInt + globals()['NEI'] = NEI + NI5 = NamedInt('test', 5) + self.assertEqual(NI5, 5) + test_pickle_dump_load(self.assertEqual, NI5, 5) + self.assertEqual(NEI.y.value, 2) + test_pickle_dump_load(self.assertTrue, NEI.y) + + def test_subclasses_without_direct_pickle_support(self): + class NamedInt(int): + __qualname__ = 'NamedInt' + def __new__(cls, *args): + _args = args + name, args = args[0], args[1:] + if len(args) == 0: + raise TypeError("name and value must be specified") + self = int.__new__(cls, *args) + self._intname = name + self._args = _args + return self + @property + def __name__(self): + return self._intname + def __repr__(self): + # repr() is updated to include the name and type info + return "%s(%r, %s)" % (type(self).__name__, + self.__name__, + int.__repr__(self)) + def __str__(self): + # str() is unchanged, even if it relies on the repr() fallback + base = int + base_str = base.__str__ + if base_str.__objclass__ is object: + return base.__repr__(self) + return base_str(self) + # for simplicity, we only define one operator that + # propagates expressions + def __add__(self, other): + temp = int(self) + int( other) + if isinstance(self, NamedInt) and isinstance(other, NamedInt): + return NamedInt( + '(%s + %s)' % (self.__name__, other.__name__), + temp ) + else: + return temp + + class NEI(NamedInt, Enum): + __qualname__ = 'NEI' + x = ('the-x', 1) + y = ('the-y', 2) + + self.assertTrue(NEI.__new__ is Enum.__new__) + self.assertEqual(repr(NEI.x + NEI.y), "NamedInt('(the-x + the-y)', 3)") + globals()['NamedInt'] = NamedInt + globals()['NEI'] = NEI + NI5 = NamedInt('test', 5) + self.assertEqual(NI5, 5) + self.assertEqual(NEI.y.value, 2) + test_pickle_exception(self.assertRaises, TypeError, NEI.x) + test_pickle_exception(self.assertRaises, PicklingError, NEI) + + def test_subclasses_without_direct_pickle_support_using_name(self): + class NamedInt(int): + __qualname__ = 'NamedInt' + def __new__(cls, *args): + _args = args + name, args = args[0], args[1:] + if len(args) == 0: + raise TypeError("name and value must be specified") + self = int.__new__(cls, *args) + self._intname = name + self._args = _args + return self + @property + def __name__(self): + return self._intname + def __repr__(self): + # repr() is updated to include the name and type info + return "%s(%r, %s)" % (type(self).__name__, + self.__name__, + int.__repr__(self)) + def __str__(self): + # str() is unchanged, even if it relies on the repr() fallback + base = int + base_str = base.__str__ + if base_str.__objclass__ is object: + return base.__repr__(self) + return base_str(self) + # for simplicity, we only define one operator that + # propagates expressions + def __add__(self, other): + temp = int(self) + int( other) + if isinstance(self, NamedInt) and isinstance(other, NamedInt): + return NamedInt( + '(%s + %s)' % (self.__name__, other.__name__), + temp ) + else: + return temp + + class NEI(NamedInt, Enum): + __qualname__ = 'NEI' + x = ('the-x', 1) + y = ('the-y', 2) + def __reduce_ex__(self, proto): + return getattr, (self.__class__, self._name_) + + self.assertTrue(NEI.__new__ is Enum.__new__) + self.assertEqual(repr(NEI.x + NEI.y), "NamedInt('(the-x + the-y)', 3)") + globals()['NamedInt'] = NamedInt + globals()['NEI'] = NEI + NI5 = NamedInt('test', 5) + self.assertEqual(NI5, 5) + self.assertEqual(NEI.y.value, 2) + test_pickle_dump_load(self.assertTrue, NEI.y) + test_pickle_dump_load(self.assertTrue, NEI) + + def test_tuple_subclass(self): + class SomeTuple(tuple, Enum): + __qualname__ = 'SomeTuple' + first = (1, 'for the money') + second = (2, 'for the show') + third = (3, 'for the music') + self.assertTrue(type(SomeTuple.first) is SomeTuple) + self.assertTrue(isinstance(SomeTuple.second, tuple)) + self.assertEqual(SomeTuple.third, (3, 'for the music')) + globals()['SomeTuple'] = SomeTuple + test_pickle_dump_load(self.assertTrue, SomeTuple.first) + + def test_duplicate_values_give_unique_enum_items(self): + class AutoNumber(Enum): + __order__ = 'enum_m enum_d enum_y' + enum_m = () + enum_d = () + enum_y = () + def __new__(cls): + value = len(cls.__members__) + 1 + obj = object.__new__(cls) + obj._value_ = value + return obj + def __int__(self): + return int(self._value_) + self.assertEqual(int(AutoNumber.enum_d), 2) + self.assertEqual(AutoNumber.enum_y.value, 3) + self.assertTrue(AutoNumber(1) is AutoNumber.enum_m) + self.assertEqual( + list(AutoNumber), + [AutoNumber.enum_m, AutoNumber.enum_d, AutoNumber.enum_y], + ) + + def test_inherited_new_from_enhanced_enum(self): + class AutoNumber2(Enum): + def __new__(cls): + value = len(cls.__members__) + 1 + obj = object.__new__(cls) + obj._value_ = value + return obj + def __int__(self): + return int(self._value_) + class Color(AutoNumber2): + __order__ = 'red green blue' + red = () + green = () + blue = () + self.assertEqual(len(Color), 3, "wrong number of elements: %d (should be %d)" % (len(Color), 3)) + self.assertEqual(list(Color), [Color.red, Color.green, Color.blue]) + if pyver >= 3.0: + self.assertEqual(list(map(int, Color)), [1, 2, 3]) + + def test_inherited_new_from_mixed_enum(self): + class AutoNumber3(IntEnum): + def __new__(cls): + value = len(cls.__members__) + 1 + obj = int.__new__(cls, value) + obj._value_ = value + return obj + class Color(AutoNumber3): + red = () + green = () + blue = () + self.assertEqual(len(Color), 3, "wrong number of elements: %d (should be %d)" % (len(Color), 3)) + Color.red + Color.green + Color.blue + + def test_ordered_mixin(self): + class OrderedEnum(Enum): + def __ge__(self, other): + if self.__class__ is other.__class__: + return self._value_ >= other._value_ + return NotImplemented + def __gt__(self, other): + if self.__class__ is other.__class__: + return self._value_ > other._value_ + return NotImplemented + def __le__(self, other): + if self.__class__ is other.__class__: + return self._value_ <= other._value_ + return NotImplemented + def __lt__(self, other): + if self.__class__ is other.__class__: + return self._value_ < other._value_ + return NotImplemented + class Grade(OrderedEnum): + __order__ = 'A B C D F' + A = 5 + B = 4 + C = 3 + D = 2 + F = 1 + self.assertEqual(list(Grade), [Grade.A, Grade.B, Grade.C, Grade.D, Grade.F]) + self.assertTrue(Grade.A > Grade.B) + self.assertTrue(Grade.F <= Grade.C) + self.assertTrue(Grade.D < Grade.A) + self.assertTrue(Grade.B >= Grade.B) + + def test_extending2(self): + def bad_extension(): + class Shade(Enum): + def shade(self): + print(self.name) + class Color(Shade): + red = 1 + green = 2 + blue = 3 + class MoreColor(Color): + cyan = 4 + magenta = 5 + yellow = 6 + self.assertRaises(TypeError, bad_extension) + + def test_extending3(self): + class Shade(Enum): + def shade(self): + return self.name + class Color(Shade): + def hex(self): + return '%s hexlified!' % self.value + class MoreColor(Color): + cyan = 4 + magenta = 5 + yellow = 6 + self.assertEqual(MoreColor.magenta.hex(), '5 hexlified!') + + def test_no_duplicates(self): + def bad_duplicates(): + class UniqueEnum(Enum): + def __init__(self, *args): + cls = self.__class__ + if any(self.value == e.value for e in cls): + a = self.name + e = cls(self.value).name + raise ValueError( + "aliases not allowed in UniqueEnum: %r --> %r" + % (a, e) + ) + class Color(UniqueEnum): + red = 1 + green = 2 + blue = 3 + class Color(UniqueEnum): + red = 1 + green = 2 + blue = 3 + grene = 2 + self.assertRaises(ValueError, bad_duplicates) + + def test_reversed(self): + self.assertEqual( + list(reversed(self.Season)), + [self.Season.WINTER, self.Season.AUTUMN, self.Season.SUMMER, + self.Season.SPRING] + ) + + def test_init(self): + class Planet(Enum): + MERCURY = (3.303e+23, 2.4397e6) + VENUS = (4.869e+24, 6.0518e6) + EARTH = (5.976e+24, 6.37814e6) + MARS = (6.421e+23, 3.3972e6) + JUPITER = (1.9e+27, 7.1492e7) + SATURN = (5.688e+26, 6.0268e7) + URANUS = (8.686e+25, 2.5559e7) + NEPTUNE = (1.024e+26, 2.4746e7) + def __init__(self, mass, radius): + self.mass = mass # in kilograms + self.radius = radius # in meters + @property + def surface_gravity(self): + # universal gravitational constant (m3 kg-1 s-2) + G = 6.67300E-11 + return G * self.mass / (self.radius * self.radius) + self.assertEqual(round(Planet.EARTH.surface_gravity, 2), 9.80) + self.assertEqual(Planet.EARTH.value, (5.976e+24, 6.37814e6)) + + def test_nonhash_value(self): + class AutoNumberInAList(Enum): + def __new__(cls): + value = [len(cls.__members__) + 1] + obj = object.__new__(cls) + obj._value_ = value + return obj + class ColorInAList(AutoNumberInAList): + __order__ = 'red green blue' + red = () + green = () + blue = () + self.assertEqual(list(ColorInAList), [ColorInAList.red, ColorInAList.green, ColorInAList.blue]) + self.assertEqual(ColorInAList.red.value, [1]) + self.assertEqual(ColorInAList([1]), ColorInAList.red) + + def test_conflicting_types_resolved_in_new(self): + class LabelledIntEnum(int, Enum): + def __new__(cls, *args): + value, label = args + obj = int.__new__(cls, value) + obj.label = label + obj._value_ = value + return obj + + class LabelledList(LabelledIntEnum): + unprocessed = (1, "Unprocessed") + payment_complete = (2, "Payment Complete") + + self.assertEqual(list(LabelledList), [LabelledList.unprocessed, LabelledList.payment_complete]) + self.assertEqual(LabelledList.unprocessed, 1) + self.assertEqual(LabelledList(1), LabelledList.unprocessed) + +class TestUnique(unittest.TestCase): + """2.4 doesn't allow class decorators, use function syntax.""" + + def test_unique_clean(self): + class Clean(Enum): + one = 1 + two = 'dos' + tres = 4.0 + unique(Clean) + class Cleaner(IntEnum): + single = 1 + double = 2 + triple = 3 + unique(Cleaner) + + def test_unique_dirty(self): + try: + class Dirty(Enum): + __order__ = 'one two tres' + one = 1 + two = 'dos' + tres = 1 + unique(Dirty) + except ValueError: + exc = sys.exc_info()[1] + message = exc.args[0] + self.assertTrue('tres -> one' in message) + + try: + class Dirtier(IntEnum): + __order__ = 'single double triple turkey' + single = 1 + double = 1 + triple = 3 + turkey = 3 + unique(Dirtier) + except ValueError: + exc = sys.exc_info()[1] + message = exc.args[0] + self.assertTrue('double -> single' in message) + self.assertTrue('turkey -> triple' in message) + + +class TestMe(unittest.TestCase): + + pass + +if __name__ == '__main__': + unittest.main() diff --git a/anknotes/error.py b/anknotes/error.py new file mode 100644 index 0000000..daa69f5 --- /dev/null +++ b/anknotes/error.py @@ -0,0 +1,59 @@ +import errno +from anknotes.evernote.edam.error.ttypes import EDAMErrorCode +from anknotes.logging import log_error, log, showInfo, show_tooltip + + +class RateLimitErrorHandling: + IgnoreError, ToolTipError, AlertError = range(3) + + +EDAM_RATE_LIMIT_ERROR_HANDLING = RateLimitErrorHandling.ToolTipError +DEBUG_RAISE_API_ERRORS = False + +latestSocketError = {'code': 0, 'friendly_error_msg': '', 'constant': ''} + + +def HandleSocketError(e, strErrorBase): + global latestSocketError + errorcode = e[0] + friendly_error_msgs = { + errno.ECONNREFUSED: "Connection was refused", + errno.WSAECONNRESET: "Connection was reset or forcibly closed by the remote host", + errno.ETIMEDOUT: "Connection timed out" + } + error_constant = errno.errorcode[errorcode] + if errorcode in friendly_error_msgs: + strError = friendly_error_msgs[errorcode] + else: + strError = "Unhandled socket error (%s) occurred" % error_constant + latestSocketError = {'code': errorcode, 'friendly_error_msg': strError, 'constant': error_constant} + strError = "Error: %s while %s\r\n" % (strError, strErrorBase) + log_error(" SocketError.%s: " % error_constant + strError) + log_error(str(e)) + log(" SocketError.%s: " % error_constant + strError, 'api') + if EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.AlertError: + showInfo(strError) + elif EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.ToolTipError: + show_tooltip(strError) + return True + + +latestEDAMRateLimit = 0 + + +def HandleEDAMRateLimitError(e, strError): + global latestEDAMRateLimit + if not e.errorCode is EDAMErrorCode.RATE_LIMIT_REACHED: + return False + latestEDAMRateLimit = e.rateLimitDuration + m, s = divmod(e.rateLimitDuration, 60) + strError = "Error: Rate limit has been reached while %s\r\n" % strError + strError += "Please retry your request in {} min".format("%d:%02d" % (m, s)) + log_strError = " EDAMErrorCode.RATE_LIMIT_REACHED: " + strError.replace('\r\n', '\n') + log_error(log_strError) + log(log_strError, 'api') + if EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.AlertError: + showInfo(strError) + elif EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.ToolTipError: + show_tooltip(strError) + return True diff --git a/anknotes/evernote/edam/notestore/ttypes.py b/anknotes/evernote/edam/notestore/ttypes.py index 9ef9ab7..c94cf54 100644 --- a/anknotes/evernote/edam/notestore/ttypes.py +++ b/anknotes/evernote/edam/notestore/ttypes.py @@ -1605,6 +1605,7 @@ def __init__(self, startIndex=None, totalNotes=None, notes=None, stoppedWords=No self.startIndex = startIndex self.totalNotes = totalNotes self.notes = notes + """:type : list[NoteMetadata]""" self.stoppedWords = stoppedWords self.searchedWords = searchedWords self.updateCount = updateCount diff --git a/anknotes/extra/ancillary/FrontTemplate-Processed.htm b/anknotes/extra/ancillary/FrontTemplate-Processed.htm new file mode 100644 index 0000000..abca9e6 --- /dev/null +++ b/anknotes/extra/ancillary/FrontTemplate-Processed.htm @@ -0,0 +1,123 @@ +<div id='Template-{{Type}}'> +<div id='Card-{{Card}}'> +<div id='Deck-{{Deck}}'> +<div id='Side-Back'> +<header id='Header-Avi'><h2> +<div id='Field-Title-Prompt'>What is the Note's Title???</div> +<div id='Field-Title'>{{Title}} </div> +</h2></header> +<div id='debug'></div> +<hr id=answer> +{{#See_Also}} +<div id='Header-Links'> +<span class='Field-See_Also'> +<a href='javascript:;' onclick='scrollToElementVisible("Field-See_Also")' class='header'>See Also</a>: +</span> +{{#TOC}} +<span class='Field-TOC'> +<a href='javascript:;' onclick='scrollToElementToggle("Field-TOC")' class='header'>[TOC]</a> +</span> +{{/TOC}} + +{{#Outline}} +{{#TOC}} +<span class='Field-See_Also'> | </span> +{{/TOC}} +<span class='Field-Outline'> +<a href='javascript:;' onclick='scrollToElementToggle("Field-Outline")' class='header'>[Outline]</a> +</span> +{{/Outline}} +</div> +{{/See_Also}} + +<div id='Field-Content'>{{Content}}</div> +<div id='Field-Cloze-Content'>{{cloze:Content}}</div> + +{{#Extra}} +<div id='Field-Extra-Front'> +<HR> +<span class='header'><u>Note</u>: Additional Information is Available</span></span> +</div> +<div id='Field-Extra'> +<HR> +<span class='header'><u>Additional Info</u>: </span></span> +{{Extra}} +<BR><BR> +</div> +{{/Extra}} + +{{#Tags}} +<div id='Tags'><span class='header'><u>Tags</u>: </span>{{Tags}}</div> +{{/Tags}} + +{{#See_Also}} +<div id='Field-See_Also'> +<HR> +{{See_Also}} +</div> +{{/See_Also}} + +{{#TOC}} +<div id='Field-TOC'> +<BR><HR> +{{TOC}} +</div> +{{/TOC}} + +{{#Outline}} +<div id='Field-Outline'> +<BR><HR> +{{Outline}} +</div> +{{/Outline}} + + +</div> +</div> +</div> +</div> + +<script> + function setElementDisplay(id,show) { + el = document.getElementById(id) + if (el == null) { return; } + // Assuming if display is not set, it is set to none by CSS + if (show === 0) { show = (el.style.display == 'none' || el.style.display == ''); } + el.style.display = (show ? 'block' : 'none') + } + function hideElement(id) { + setElementDisplay(id, false); + } + function showElement(id) { + setElementDisplay(id, true); + } + function toggleElement(id) { + setElementDisplay(id, 0); + } + + function scrollToElement(id, show) { + setElementDisplay(id, show); + el = document.getElementById(id) + if (el == null) { return; } + window.scroll(0,findPos(el)); + } + + function scrollToElementToggle(id) { + scrollToElement(id, 0); + } + + function scrollToElementVisible(id) { + scrollToElement(id, true); + } + +//Finds y value of given object +function findPos(obj) { + var curtop = 0; + if (obj.offsetParent) { + do { + curtop += obj.offsetTop; + } while (obj = obj.offsetParent); + return [curtop]; + } +} +</script> \ No newline at end of file diff --git a/anknotes/extra/ancillary/FrontTemplate.htm b/anknotes/extra/ancillary/FrontTemplate.htm new file mode 100644 index 0000000..0631843 --- /dev/null +++ b/anknotes/extra/ancillary/FrontTemplate.htm @@ -0,0 +1,142 @@ +<div id='Template-{{Type}}'> +<div id='Card-{{Card}}'> +<div id='Deck-{{Deck}}'> +<div id='Side-Front'> +<div id='Header'> +<header id='Header-Avi'><h2> +<div id='Field-%(Title)s-Prompt'>What is the Note's Title???</div> +<div id='Field-%(Title)s'>{{%(Title)s}} </div> +</h2></header> +</div> + +<hr id=answer> +{{#%(See Also)s}} +<div id='Header-Links'> +<span class='Field-%(See Also)s'> +<a href='javascript:;' onclick='scrollToElementVisible("Field-%(See Also)s")' class='header'>See Also</a>: +</span> +{{#%(TOC)s}} +<span class='Field-%(TOC)s'> +<a href='javascript:;' onclick='scrollToElementToggle("Field-%(TOC)s")' class='header'>[TOC]</a> +</span> +{{/%(TOC)s}} + +{{#%(Outline)s}} +{{#%(TOC)s}} +<span class='Field-%(See Also)s'> | </span> +{{/%(TOC)s}} +<span class='Field-%(Outline)s'> +<a href='javascript:;' onclick='scrollToElementToggle("Field-%(Outline)s")' class='header'>[Outline]</a> +</span> +{{/%(Outline)s}} +</div> +{{/%(See Also)s}} + +<div id='Field-%(Content)s'>{{%(Content)s}}</div> +<div id='Field-Cloze-%(Content)s'>{{cloze:%(Content)s}}</div> + +{{#%(Extra)s}} +<div id='Field-%(Extra)s-Front'> +<HR> +<span class='header'><u>Note</u>: Additional Information is Available</span></span> +</div> +<div id='Field-%(Extra)s'> +<HR> +<span class='header'><u>Additional Info</u>: </span></span> +{{%(Extra)s}} +<BR><BR> +</div> +{{/%(Extra)s}} + +<div id='Footer-Line'> +<span id='Link-EN-Self'></span> +{{#Tags}} +<span id='Tags'><span class='header'><u>Tags</u>: </span>{{Tags}}</span> +{{/Tags}} +</div> + +{{#%(See Also)s}} +<div id='Field-%(See Also)s'> +<HR> +{{%(See Also)s}} +</div> +{{/%(See Also)s}} + +{{#%(TOC)s}} +<div id='Field-%(TOC)s'> +<BR><HR> +{{%(TOC)s}} +</div> +{{/%(TOC)s}} + +{{#%(Outline)s}} +<div id='Field-%(Outline)s'> +<BR><HR> +{{%(Outline)s}} +</div> +{{/%(Outline)s}} + + +</div> +</div> +</div> +</div> + + + +<script> + evernote_guid_prefix = '%(Evernote GUID Prefix)s' + evernote_uid = '%(Evernote UID)s' + evernote_shard = '%(Evernote shard)s' + function generateEvernoteLink(guid_field) { + guid = guid_field.replace(evernote_guid_prefix, '') + en_link = 'evernote://view/'+evernote_uid+'/'+evernote_shard+'/'+guid+'/'+guid+'/' + return en_link + } + function setElementDisplay(id,show) { + el = document.getElementById(id) + if (el == null) { return; } + // Assuming if display is not set, it is set to none by CSS + if (show === 0) { show = (el.style.display == 'none' || el.style.display == ''); } + el.style.display = (show ? 'block' : 'none') + } + function hideElement(id) { + setElementDisplay(id, false); + } + function showElement(id) { + setElementDisplay(id, true); + } + function toggleElement(id) { + setElementDisplay(id, 0); + } + + function scrollToElement(id, show) { + setElementDisplay(id, show); + el = document.getElementById(id) + if (el == null) { return; } + window.scroll(0,findPos(el)); + } + + function scrollToElementToggle(id) { + scrollToElement(id, 0); + } + + function scrollToElementVisible(id) { + scrollToElement(id, true); + } + +//Finds y value of given object +function findPos(obj) { + var curtop = 0; + if (obj.offsetParent) { + do { + curtop += obj.offsetTop; + } while (obj = obj.offsetParent); + return [curtop]; + } +} + +document.getElementById('Link-EN-Self').innerHTML = "<a href='" + generateEvernoteLink('{{Evernote GUID}}') + "'>Open in EN</a> <span class='separator'> | </span>" +document.getElementById('Field-Title').innerHTML = "<a href='" + generateEvernoteLink('{{Evernote GUID}}') + "'>" + document.getElementById('Field-Title').innerHTML + "</a>" + +</script> \ No newline at end of file diff --git a/anknotes/extra/ancillary/_AviAnkiCSS.css b/anknotes/extra/ancillary/_AviAnkiCSS.css new file mode 100644 index 0000000..031fc43 --- /dev/null +++ b/anknotes/extra/ancillary/_AviAnkiCSS.css @@ -0,0 +1,356 @@ +/* @import url("_AviAnkiCSS.css") */ + +/******************************************************************************************************* + Default Card Rules +*******************************************************************************************************/ + +.card { + font-family: Tahoma; + font-size: 20px; + text-align: left; + color: black; + background-color: white; +} + +/******************************************************************************************************* + Header rectangles, which sit at the top of every single card +*******************************************************************************************************/ + +header { +background: #000; +width:100%; +text-align: center; +/* border:3px #990000 solid; */ +} + +header h2 { +color: #fff; +text-shadow: 1px 1px 2px rgba(0,0,0,0.2); +} + +/******************************************************************************************************* + TITLE Fields +*******************************************************************************************************/ + +.card #Field-Title, .card #Field-Title-Prompt { + text-align: center; + font-weight: bold; + font-size: 72px; + font-variant: small-caps; + padding:10px; + /* color: #A40F2D; */ +} + + +.card #Header a { + text-decoration: none; +} + +.card #Header:hover #Field-Title span.link, .card #Header:hover #Field-Title-Prompt span.link { + border-bottom: none; +} + +.card #Field-Title-Prompt { + width: 100% ; + position: fixed; + font-size: 48px; + color: #a90030; +} + +.card #Header #Field-Title span.link, .card #Header #Field-Title-Prompt span.link { + border-bottom: 5px black solid; +} + +.card #Header #Field-Title-Prompt span.link { + border-bottom-color: #a90030; +} + + .card a:hover #Field-Title-Prompt { + color: rgb(210, 13, 13); + } + +/******************************************************************************************************* + Header bars with custom gradient backgrounds +*******************************************************************************************************/ + + +#Header-BlueWhiteRed, #Header-Avi h2, +#Header-Avi-BlueRed h2, +#Header-RedOrange h2 +{ +text-shadow: 1px 1px 2px rgba(0,0,0,0.2); +text-shadow: black 0 1px; +} + +#Header-RedOrange h2, #Header-RedOrange h2 { +color:#990000; +} + +a:hover #Header-RedOrange h2 { + color: rgb(106, 6, 6); +} + +.card #Header-RedOrange #Field-Title span.link { + border-bottom-color:#990000; +} + +#Header-RedOrange { +/* Background gradient code */ +background: -moz-linear-gradient(left, #ff1a00 0%, #fff200 36%, #fff200 58%, #ff1a00 100%); /* FF3.6+ */ +background: -webkit-gradient(linear, left top, right top, color-stop(0%,#ff1a00), color-stop(36%,#fff200), color-stop(58%,#fff200), color-stop(100%,#ff1a00)); /* Chrome,Safari4+ */ +background: -webkit-linear-gradient(left, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* Chrome10+,Safari5.1+ */ +background: -o-linear-gradient(left, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* Opera 11.10+ */ +background: -ms-linear-gradient(left, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* IE10+ */ +background: linear-gradient(to right, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* W3C */ +filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ff1a00', endColorstr='#ff1a00',GradientType=1 ); /* IE6-9 */ +/* z-index: 100; /* the stack order: foreground */ +/* border-bottom:1px #990000 solid; */ +border:3px #990000 solid; +} + + +#Header-BlueWhiteRed h2, #Header-Avi h2 { +color:#004C99; +} + +a:hover #Header-BlueWhiteRed h2, a:hover #Header-Avi h2 { +color: rgb(10, 121, 243); +} + +.card #Header-BlueWhiteRed #Field-Title span.link, .card #Header-Avi #Field-Title span.link { + border-bottom-color:#004C99; +} + +#Header-BlueWhiteRed, #Header-Avi { + /* Background gradient code */ +background: #3b679e; /* Old browsers */ +background: -moz-linear-gradient(left, #3b679e 0%, #ffffff 38%, #ffffff 59%, #ff1111 100%); /* FF3.6+ */ +background: -webkit-gradient(linear, left top, right top, color-stop(0%,#3b679e), color-stop(38%,#ffffff), color-stop(59%,#ffffff), color-stop(100%,#ff1111)); /* Chrome,Safari4+ */ +background: -webkit-linear-gradient(left, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* Chrome10+,Safari5.1+ */ +background: -o-linear-gradient(left, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* Opera 11.10+ */ +background: -ms-linear-gradient(left, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* IE10+ */ +background: linear-gradient(to right, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* W3C */ +filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3b679e', endColorstr='#ff1111',GradientType=1 ); /* IE6-9 */ +/* z-index: 100; /* the stack order: foreground */ +/* border-bottom:1px #004C99 solid; */ +border:3px #990000 solid; +} + +#Header-Avi-BlueRed h2, #Header-Avi-BlueRed h2 { +color:#80A6CC; +} + +a:hover #Header-Avi-BlueRed h2 { +color: rgb(241, 135, 154); +} + + +.card #Header-BlueRed #Field-Title span.link { + border-bottom-color:#80A6CC; +} + +#Header-Avi-BlueRed { + /* Background gradient code */ +background: -moz-linear-gradient(left, #bf0060 0%, #0060bf 36%, #0060bf 58%, #bf0060 100%); /* FF3.6+ */ +background: -webkit-gradient(linear, left top, right top, color-stop(0%,#bf0060), color-stop(36%,#0060bf), color-stop(58%,#0060bf), color-stop(100%,#bf0060)); /* Chrome,Safari4+ */ +background: -webkit-linear-gradient(left, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* Chrome10+,Safari5.1+ */ +background: -o-linear-gradient(left, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* Opera 11.10+ */ +background: -ms-linear-gradient(left, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* IE10+ */ +background: linear-gradient(to right, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* W3C */ +filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#0060bf', endColorstr='#bf0060',GradientType=1 ); /* IE6-9 */ +/* z-index: 100; /* the stack order: foreground */ +/* border-bottom:1px #004C99 solid; */ +border:3px #004C99 solid; +} + +/******************************************************************************************************* + Headers with Links for See Also, TOC, Outline +*******************************************************************************************************/ +.card #Header-Links { + font-size: 14px; + margin-top: -20px; + margin-bottom: 10px; + font-weight: bold; +} + +.card #Field-Header-Links #Field-See_Also-Link { + color: rgb(45, 79, 201); +} + +/******************************************************************************************************* + HTML Link Elements +*******************************************************************************************************/ + +a { + color: rgb(105, 170, 53); + text-decoration: underline; +} + +a:hover { + color: rgb(135, 187, 93); + text-decoration: none; +} + +.card .See_Also a, .card .Field-See_Also a, .card #Field-Header-Links #Field-See_Also-Link a .Note_Link { + color: rgb(45, 79, 201); +} + +.card .See_Also a:hover, .card .Field-See_Also a:hover, .card #Field-Header-Links #Field-See_Also-Link a:hover .Note_Link { + color: rgb(108, 132, 217); +} + +.card .Field-TOC a , .card #Field-Header-Links #Field-TOC-Link a .Note_Link{ + color: rgb(173, 0, 0); +} + +.card .Field-TOC a:hover, .card #Field-Header-Links #Field-TOC-Link a:hover .Note_Link { + color: rgb(196, 71, 71); +} + +.card .Field-Outline a, .card #Field-Header-Links #Field-Outline-Link a .Note_Link, { + color: rgb(105, 170, 53); +} + +.card .Field-Outline a:hover , .card #Field-Header-Links #Field-Outline-Link a:hover .Note_Link{ + color: rgb(135, 187, 93); +} + +.card #Link-EN-Self a { + color: rgb(30, 155, 67) +} + +.card #Link-EN-Self a:hover { + color: rgb(107, 226, 143) +} + +/******************************************************************************************************* + TOC/Outline Headers (Automatically generated and placed in TOC/Outline fields when > 1 source note) +*******************************************************************************************************/ + +.card .TOC, .card .Outline { + font-weight: bold; +} + +.card .TOC { + color: rgb(173, 0, 0); +} + +.card .Outline { + color: rgb(105, 170, 53); +} + +.card .TOC .header, .card .Outline .header { + text-decoration: underline; + color: #bf0060; +} + +.card .TOC .header:nth-of-type(1){ + color: rgb(173, 0, 0); +} + +.card .Outline .header:nth-of-type(1) { + color: rgb(105, 170, 53); +} + + + +/******************************************************************************************************* + Per-Field Rules +*******************************************************************************************************/ + +.card #Field-Extra , #Field-Extra-Front, +.card #Field-See_Also , .card #Field-TOC , .card #Field-Outline { + font-size: 14px; +} + +.card #Footer-Line { + font-size: 10px; +} + +.card #Field-See_Also ol { + padding-top: 0px; + margin-top: 0px; +} + +.card #Field-See_Also hr { + padding-top: 0px; + padding-bottom: 0px; + margin-top: 5px; + margin-bottom: 10px; +} + +/******************************************************************************************************* + Extra Field/Tags Rules +*******************************************************************************************************/ + +.card #Field-Extra , #Field-Extra-Front, +.card #Tags { + color: #aaa; +} + +.card #Field-Extra .header, #Field-Extra-Front .header { + color: #666; +} + +.card #Field-Extra .header, #Field-Extra-Front .header , #Tags .header { + font-weight: bold; +} + +.card #Field-Extra-Front { + color: #444; +} + +/******************************************************************************************************* + Special Span Classes +*******************************************************************************************************/ + +.card .occluded { + color: #444; +} + +.card .See_Also, .card .Field-See_Also, .card .separator { + color: rgb(45, 79, 201); + font-weight: bold; +} + +/******************************************************************************************************* + Default Visibility Rules +*******************************************************************************************************/ + + +.card #Field-Cloze-Content, +.card #Field-Title-Prompt, +.card #Side-Front #Footer-Line, +.card #Side-Front .occluded, +.card #Side-Front #Field-See_Also, +.card #Field-TOC, +.card #Field-Outline, +.card #Side-Front #Field-Extra, +.card #Side-Back #Field-Extra-Front, +.card #Card-EvernoteReviewCloze #Field-Content +{ + display: none; +} + +.card #Side-Front #Header-Links, +.card #Card-EvernoteReview #Side-Front #Field-Content, +.card #Card-EvernoteReviewReversed #Side-Front #Field-Title +{ + visibility: hidden; +} + +.card #Card-EvernoteReviewCloze #Field-Cloze-Content, +.card #Card-EvernoteReviewReversed #Side-Front #Field-Title-Prompt, +.card #Side-Back #Field-See_Also +{ + display: block; +} + +/******************************************************************************************************* + Rules for Anki-Generated Classes +*******************************************************************************************************/ + +.cloze { + font-weight: bold; + color: blue; +} \ No newline at end of file diff --git a/anknotes/extra/ancillary/_attributes.css b/anknotes/extra/ancillary/_attributes.css new file mode 100644 index 0000000..8a8a018 --- /dev/null +++ b/anknotes/extra/ancillary/_attributes.css @@ -0,0 +1,115 @@ + +/******************************************************************************************************* + Helpful Attributes +*******************************************************************************************************/ + +/* + + Colors: + <OL> + Levels + 'OL': { + 1: { + 'Default': 'rgb(106, 0, 129);', + 'Hover': 'rgb(168, 0, 204);' + }, + 2: { + 'Default': 'rgb(235, 0, 115);', + 'Hover': 'rgb(255, 94, 174);' + }, + 3: { + 'Default': 'rgb(186, 0, 255);', + 'Hover': 'rgb(213, 100, 255);' + }, + 4: { + 'Default': 'rgb(129, 182, 255);', + 'Hover': 'rgb(36, 130, 255);' + }, + 5: { + 'Default': 'rgb(232, 153, 220);', + 'Hover': 'rgb(142, 32, 125);' + }, + 6: { + 'Default': 'rgb(201, 213, 172);', + 'Hover': 'rgb(130, 153, 77);' + }, + 7: { + 'Default': 'rgb(231, 179, 154);', + 'Hover': 'rgb(215, 129, 87);' + }, + 8: { + 'Default': 'rgb(249, 136, 198);', + 'Hover': 'rgb(215, 11, 123);' + } + Headers + Auto TOC: + color: rgb(11, 59, 225); + Modifiers + Orange: + color: rgb(222, 87, 0); + Orange (Light): + color: rgb(250, 122, 0); + Dark Red/Pink: + color: rgb(164, 15, 45); + Pink Alternative LVL1: + color: rgb(188, 0, 88); + + Header Boxes + Red-Orange: + Gradient Start: + color: rgb(255, 26, 0); + Gradient End: + color: rgb(255, 242, 0); + Title: + color: rgb(153, 0, 0); + color: rgb(106, 6, 6); + Blue-White-Red + Gradient Start: + color: rgb(59, 103, 158); + Gradient End: + color: rgb(255, 17, 17); + Title: + color: rgb(0, 76, 153); + color: rgb(10, 121, 243); + Old Border: + color: rgb(0, 76, 153); + Avi-Blue-Red + Gradient Start: + color: rgb(0, 96, 191); + Gradient End: + color: rgb(191, 0, 96); + Title: + color: rgb(128, 166, 204); + color: rgb(241, 135, 154); + Old Border: + color: rgb(0, 76, 153); + Borders + color: rgb(153, 0, 0); + + Titles: + Field Title Prompt: + color: rgb(169, 0, 48); + + See Also (Link + Hover) + See Also: + color: rgb(45, 79, 201); + color: rgb(108, 132, 217); + TOC: + color: rgb(173, 0, 0); + color: rgb(196, 71, 71); + Outline: + color: rgb(105, 170, 53); + color: rgb(135, 187, 93); + + Evernote Anknotes Self-Referential Link + color: rgb(30, 155, 67) + color: rgb(107, 226, 143) + + Evernote Classic (In-App) Note Link + color: rgb(105, 170, 53); + color: rgb(135, 187, 93); + + Unused: + color: rgb(122, 220, 241); + +*/ \ No newline at end of file diff --git a/anknotes/extra/ancillary/enml2.dtd b/anknotes/extra/ancillary/enml2.dtd new file mode 100644 index 0000000..8419976 --- /dev/null +++ b/anknotes/extra/ancillary/enml2.dtd @@ -0,0 +1,592 @@ +<!-- + + Evernote Markup Language (ENML) 2.0 DTD + + This expresses the structure of an XML document that can be used as the + 'content' of a Note within Evernote's data model. + The Evernote service will reject attempts to create or update notes if + their contents do not validate against this DTD. + + This is based on a subset of XHTML which is intentionally broadened to + reject less real-world HTML, to reduce the likelihood of synchronization + failures. This means that all attributes are defined as CDATA instead of + more-restrictive types, and every HTML element may embed every other + HTML element. + + Copyright (c) 2007-2009 Evernote Corp. + + $Date: 2007/10/15 18:00:00 $ + +--> + +<!--=========== External character mnemonic entities ===================--> + +<!ENTITY % HTMLlat1 PUBLIC + "-//W3C//ENTITIES Latin 1 for XHTML//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml-lat1.ent"> +%HTMLlat1; + +<!ENTITY % HTMLsymbol PUBLIC + "-//W3C//ENTITIES Symbols for XHTML//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml-symbol.ent"> +%HTMLsymbol; + +<!ENTITY % HTMLspecial PUBLIC + "-//W3C//ENTITIES Special for XHTML//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml-special.ent"> +%HTMLspecial; + +<!--=================== Generic Attributes ===============================--> + +<!ENTITY % coreattrs + "style CDATA #IMPLIED + title CDATA #IMPLIED" + > + +<!ENTITY % i18n + "lang CDATA #IMPLIED + xml:lang CDATA #IMPLIED + dir CDATA #IMPLIED" + > + +<!ENTITY % focus + "accesskey CDATA #IMPLIED + tabindex CDATA #IMPLIED" + > + +<!ENTITY % attrs + "%coreattrs; + %i18n;" + > + +<!ENTITY % TextAlign + "align CDATA #IMPLIED" + > + +<!ENTITY % cellhalign + "align CDATA #IMPLIED + char CDATA #IMPLIED + charoff CDATA #IMPLIED" + > + +<!ENTITY % cellvalign + "valign CDATA #IMPLIED" + > + +<!ENTITY % AnyContent + "( #PCDATA | + a | + abbr | + acronym | + address | + area | + b | + bdo | + big | + blockquote | + br | + caption | + center | + cite | + code | + col | + colgroup | + dd | + del | + dfn | + div | + dl | + dt | + em | + en-crypt | + en-media | + en-todo | + font | + h1 | + h2 | + h3 | + h4 | + h5 | + h6 | + hr | + i | + img | + ins | + kbd | + li | + map | + ol | + p | + pre | + q | + s | + samp | + small | + span | + strike | + strong | + sub | + sup | + table | + tbody | + td | + tfoot | + th | + thead | + tr | + tt | + u | + ul | + var )*" + > + +<!--=========== Evernote-specific Elements and Attributes ===============--> + +<!ELEMENT en-note %AnyContent;> +<!ATTLIST en-note + %attrs; + bgcolor CDATA #IMPLIED + text CDATA #IMPLIED + xmlns CDATA #FIXED 'http://xml.evernote.com/pub/enml2.dtd' + > + +<!ELEMENT en-crypt (#PCDATA)> +<!ATTLIST en-crypt + hint CDATA #IMPLIED + cipher CDATA "RC2" + length CDATA "64" + > + +<!ELEMENT en-todo EMPTY> +<!ATTLIST en-todo + checked (true|false) "false" + > + +<!ELEMENT en-media EMPTY> +<!ATTLIST en-media + %attrs; + type CDATA #REQUIRED + hash CDATA #REQUIRED + height CDATA #IMPLIED + width CDATA #IMPLIED + usemap CDATA #IMPLIED + align CDATA #IMPLIED + border CDATA #IMPLIED + hspace CDATA #IMPLIED + vspace CDATA #IMPLIED + longdesc CDATA #IMPLIED + alt CDATA #IMPLIED + > + +<!--=========== Simplified HTML Elements and Attributes ===============--> + +<!ELEMENT a %AnyContent;> +<!ATTLIST a + %attrs; + %focus; + charset CDATA #IMPLIED + type CDATA #IMPLIED + name CDATA #IMPLIED + href CDATA #IMPLIED + hreflang CDATA #IMPLIED + rel CDATA #IMPLIED + rev CDATA #IMPLIED + shape CDATA #IMPLIED + coords CDATA #IMPLIED + target CDATA #IMPLIED + > + +<!ELEMENT abbr %AnyContent;> +<!ATTLIST abbr + %attrs; + > + +<!ELEMENT acronym %AnyContent;> +<!ATTLIST acronym + %attrs; + > + +<!ELEMENT address %AnyContent;> +<!ATTLIST address + %attrs; + > + +<!ELEMENT area %AnyContent;> +<!ATTLIST area + %attrs; + %focus; + shape CDATA #IMPLIED + coords CDATA #IMPLIED + href CDATA #IMPLIED + nohref CDATA #IMPLIED + alt CDATA #IMPLIED + target CDATA #IMPLIED + > + +<!ELEMENT b %AnyContent;> +<!ATTLIST b + %attrs; + > + +<!ELEMENT bdo %AnyContent;> +<!ATTLIST bdo + %coreattrs; + lang CDATA #IMPLIED + xml:lang CDATA #IMPLIED + dir CDATA #IMPLIED + > + +<!ELEMENT big %AnyContent;> +<!ATTLIST big + %attrs; + > + +<!ELEMENT blockquote %AnyContent;> +<!ATTLIST blockquote + %attrs; + cite CDATA #IMPLIED + > + +<!ELEMENT br %AnyContent;> +<!ATTLIST br + %coreattrs; + clear CDATA #IMPLIED + > + +<!ELEMENT caption %AnyContent;> +<!ATTLIST caption + %attrs; + align CDATA #IMPLIED + > + +<!ELEMENT center %AnyContent;> +<!ATTLIST center + %attrs; + > + +<!ELEMENT cite %AnyContent;> +<!ATTLIST cite + %attrs; + > + +<!ELEMENT code %AnyContent;> +<!ATTLIST code + %attrs; + > + +<!ELEMENT col %AnyContent;> +<!ATTLIST col + %attrs; + %cellhalign; + %cellvalign; + span CDATA #IMPLIED + width CDATA #IMPLIED + > + +<!ELEMENT colgroup %AnyContent;> +<!ATTLIST colgroup + %attrs; + %cellhalign; + %cellvalign; + span CDATA #IMPLIED + width CDATA #IMPLIED + > + +<!ELEMENT dd %AnyContent;> +<!ATTLIST dd + %attrs; + > + +<!ELEMENT del %AnyContent;> +<!ATTLIST del + %attrs; + cite CDATA #IMPLIED + datetime CDATA #IMPLIED + > + +<!ELEMENT dfn %AnyContent;> +<!ATTLIST dfn + %attrs; + > + +<!ELEMENT div %AnyContent;> +<!ATTLIST div + %attrs; + %TextAlign; + > + +<!ELEMENT dl %AnyContent;> +<!ATTLIST dl + %attrs; + compact CDATA #IMPLIED + > + +<!ELEMENT dt %AnyContent;> +<!ATTLIST dt + %attrs; + > + +<!ELEMENT em %AnyContent;> +<!ATTLIST em + %attrs; + > + +<!ELEMENT font %AnyContent;> +<!ATTLIST font + %coreattrs; + %i18n; + size CDATA #IMPLIED + color CDATA #IMPLIED + face CDATA #IMPLIED + > + +<!ELEMENT h1 %AnyContent;> +<!ATTLIST h1 + %attrs; + %TextAlign; + > + +<!ELEMENT h2 %AnyContent;> +<!ATTLIST h2 + %attrs; + %TextAlign; + > + +<!ELEMENT h3 %AnyContent;> +<!ATTLIST h3 + %attrs; + %TextAlign; + > + +<!ELEMENT h4 %AnyContent;> +<!ATTLIST h4 + %attrs; + %TextAlign; + > + +<!ELEMENT h5 %AnyContent;> +<!ATTLIST h5 + %attrs; + %TextAlign; + > + +<!ELEMENT h6 %AnyContent;> +<!ATTLIST h6 + %attrs; + %TextAlign; + > + +<!ELEMENT hr %AnyContent;> +<!ATTLIST hr + %attrs; + align CDATA #IMPLIED + noshade CDATA #IMPLIED + size CDATA #IMPLIED + width CDATA #IMPLIED + > + +<!ELEMENT i %AnyContent;> +<!ATTLIST i + %attrs; + > + +<!ELEMENT img %AnyContent;> +<!ATTLIST img + %attrs; + src CDATA #IMPLIED + alt CDATA #IMPLIED + name CDATA #IMPLIED + longdesc CDATA #IMPLIED + height CDATA #IMPLIED + width CDATA #IMPLIED + usemap CDATA #IMPLIED + ismap CDATA #IMPLIED + align CDATA #IMPLIED + border CDATA #IMPLIED + hspace CDATA #IMPLIED + vspace CDATA #IMPLIED + > + +<!ELEMENT ins %AnyContent;> +<!ATTLIST ins + %attrs; + cite CDATA #IMPLIED + datetime CDATA #IMPLIED + > + +<!ELEMENT kbd %AnyContent;> +<!ATTLIST kbd + %attrs; + > + +<!ELEMENT li %AnyContent;> +<!ATTLIST li + %attrs; + type CDATA #IMPLIED + value CDATA #IMPLIED + > + +<!ELEMENT map %AnyContent;> +<!ATTLIST map + %i18n; + title CDATA #IMPLIED + name CDATA #IMPLIED + > + +<!ELEMENT ol %AnyContent;> +<!ATTLIST ol + %attrs; + type CDATA #IMPLIED + compact CDATA #IMPLIED + start CDATA #IMPLIED + > + +<!ELEMENT p %AnyContent;> +<!ATTLIST p + %attrs; + %TextAlign; + > + +<!ELEMENT pre %AnyContent;> +<!ATTLIST pre + %attrs; + width CDATA #IMPLIED + xml:space (preserve) #FIXED 'preserve' + > + +<!ELEMENT q %AnyContent;> +<!ATTLIST q + %attrs; + cite CDATA #IMPLIED + > + +<!ELEMENT s %AnyContent;> +<!ATTLIST s + %attrs; + > + +<!ELEMENT samp %AnyContent;> +<!ATTLIST samp + %attrs; + > + +<!ELEMENT small %AnyContent;> +<!ATTLIST small + %attrs; + > + +<!ELEMENT span %AnyContent;> +<!ATTLIST span + %attrs; + > + +<!ELEMENT strike %AnyContent;> +<!ATTLIST strike + %attrs; + > + +<!ELEMENT strong %AnyContent;> +<!ATTLIST strong + %attrs; + > + +<!ELEMENT sub %AnyContent;> +<!ATTLIST sub + %attrs; + > + +<!ELEMENT sup %AnyContent;> +<!ATTLIST sup + %attrs; + > + +<!ELEMENT table %AnyContent;> +<!ATTLIST table + %attrs; + summary CDATA #IMPLIED + width CDATA #IMPLIED + border CDATA #IMPLIED + cellspacing CDATA #IMPLIED + cellpadding CDATA #IMPLIED + align CDATA #IMPLIED + bgcolor CDATA #IMPLIED + > + +<!ELEMENT tbody %AnyContent;> +<!ATTLIST tbody + %attrs; + %cellhalign; + %cellvalign; + > + +<!ELEMENT td %AnyContent;> +<!ATTLIST td + %attrs; + %cellhalign; + %cellvalign; + abbr CDATA #IMPLIED + rowspan CDATA #IMPLIED + colspan CDATA #IMPLIED + nowrap CDATA #IMPLIED + bgcolor CDATA #IMPLIED + width CDATA #IMPLIED + height CDATA #IMPLIED + > + +<!ELEMENT tfoot %AnyContent;> +<!ATTLIST tfoot + %attrs; + %cellhalign; + %cellvalign; + > + +<!ELEMENT th %AnyContent;> +<!ATTLIST th + %attrs; + %cellhalign; + %cellvalign; + abbr CDATA #IMPLIED + rowspan CDATA #IMPLIED + colspan CDATA #IMPLIED + nowrap CDATA #IMPLIED + bgcolor CDATA #IMPLIED + width CDATA #IMPLIED + height CDATA #IMPLIED + > + +<!ELEMENT thead %AnyContent;> +<!ATTLIST thead + %attrs; + %cellhalign; + %cellvalign; + > + +<!ELEMENT tr %AnyContent;> +<!ATTLIST tr + %attrs; + %cellhalign; + %cellvalign; + bgcolor CDATA #IMPLIED + > + +<!ELEMENT tt %AnyContent;> +<!ATTLIST tt + %attrs; + > + +<!ELEMENT u %AnyContent;> +<!ATTLIST u + %attrs; + > + +<!ELEMENT ul %AnyContent;> +<!ATTLIST ul + %attrs; + type CDATA #IMPLIED + compact CDATA #IMPLIED + > + +<!ELEMENT var %AnyContent;> +<!ATTLIST var + %attrs; + > diff --git a/anknotes/index.html b/anknotes/extra/ancillary/index.html similarity index 100% rename from anknotes/index.html rename to anknotes/extra/ancillary/index.html diff --git a/anknotes/extra/ancillary/regex-see_also2.txt b/anknotes/extra/ancillary/regex-see_also2.txt new file mode 100644 index 0000000..b8c86b0 --- /dev/null +++ b/anknotes/extra/ancillary/regex-see_also2.txt @@ -0,0 +1,21 @@ +(?P<PrefixStrip><div><b><span[^>]*><br/></span></b></div>)? +(?P<SeeAlso>(?P<SeeAlsoPrefix><div[^>]*>)(?P<SeeAlsoHeader> + +(?:<(?:b|span|font)[^>]*>){0,3} +(?:<span[^>]*>) +(?:<b(?: style=[^>]+?)?>)? +(?P<SeeAlsoHeaderStripMe><br />(?:\r|\n|\r\n)?)? + +(?:See.Also:? + +(?:<span[^>]*> </span>)? +(?: )?) + +(?:</b>)? +(?:</span>) +(?:</(?:span|font)>)? +(?:</b>)? + + + +)(?P<SeeAlsoContents>.+))(?P<Suffix></en-note>) \ No newline at end of file diff --git a/anknotes/extra/ancillary/sorting.txt b/anknotes/extra/ancillary/sorting.txt new file mode 100644 index 0000000..a8a4b47 --- /dev/null +++ b/anknotes/extra/ancillary/sorting.txt @@ -0,0 +1,49 @@ + first = [ + 'Summary', + 'Definition', + 'Classification', + 'Types', + 'Presentation', + 'Age of Onset', + 'Si/Sx', + 'Sx', + 'Sign', + + 'MCC\'s', + 'MCC', + 'Inheritance', + 'Incidence', + 'Prognosis', + 'Mechanism', + 'MOA', + 'Pathophysiology', + + 'Indications', + 'Examples', + 'Cause', + 'Causes', + 'Causative Organisms', + 'Risk Factors', + 'Complication', + 'Complications', + 'Side Effects', + 'Drug S/E', + 'Associated Conditions', + 'A/w', + + 'Dx', + 'Physical Exam', + 'Labs', + 'Hemodynamic Parameters', + 'Lab Findings', + 'Imaging', + 'Confirmatory Test', + 'Screening Test' + ] + last = [ + 'Management', + 'Work Up', + 'Tx' + + + ] \ No newline at end of file diff --git a/anknotes/extra/graphics/Evernote.ico b/anknotes/extra/graphics/Evernote.ico new file mode 100644 index 0000000000000000000000000000000000000000..5e2e6b35d31acdbbbc74edda4599df8e70b05166 GIT binary patch literal 293950 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�jQ@XRHu#@bE&R^>-vh?~ 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%0A6`wRv9r`#HZ~+Ph%Gva<EZ_m+d~`*Rhs z|6=|O*gu26@09o_=BMiZ$g!7G@5;4p{;RgHUBvpB7r%vE-)j3O=5M&>&Ehu)|N78} z-ab>~Y6ifQb-%>FzQ1|+{$|y@e`EjT|JeQ>U(>-qxcxZzH+SIgZkY%F?<p6Fe{2fl z^Z|hXDZ~Iy`^Oy}`ClJ^F@VG(^}InXK=^;v1JF~J6RWV&Jwe_k7HEY3cVYpN0l@zj z@V}M!uJM5QKFxj79<ZPJQ3p7Lyk9Yo9-!QBT!1x_qyaT=7&*b1;<Z1xr3}1wvMl=Y zp)z&jl5_Te@Yd+xEuWQ-Mh@_Ql@7QzvX10c+NzZUqygFkqyfqS(gA6}svR54qIdAm zzK0z0?(Jm=8ZZF<XASpPKZN{0P5j3mAYBS=QVs|`2n`VTK6~&Vx)t7UFAd8(gdT(z zL=JGRrnT!h>p$--Ho_N!{mespac1*AG3NPT&puepeYyTo$1Ro}=Q!i`dma0mIhWt| z`}559{˦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 CL*`rn literal 0 HcmV?d00001 diff --git a/anknotes/extra/graphics/Evernote.png b/anknotes/extra/graphics/Evernote.png new file mode 100644 index 0000000000000000000000000000000000000000..5dead3d333a0cc8ff0e146ae9ae99d5ea21b6bd3 GIT binary patch literal 73017 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 literal 0 HcmV?d00001 diff --git a/anknotes/extra/graphics/evernote_artcore.ico b/anknotes/extra/graphics/evernote_artcore.ico new file mode 100644 index 0000000000000000000000000000000000000000..cbdb775b7f42e49a32342cd9cfcc149b4ccdb8bb GIT binary patch literal 270398 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ðXYm?g_?Xu(#kdH@PJw@(7d|Jo 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�cE}8e^b~ovJd-*=j(#!YoWid`_y*uhZ+EQz*Ay@ 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 VLAN-2)2XRBH@!$6{#%-X{}18%VI}|o literal 0 HcmV?d00001 diff --git a/anknotes/extra/graphics/evernote_artcore.png b/anknotes/extra/graphics/evernote_artcore.png new file mode 100644 index 0000000000000000000000000000000000000000..302ab5625cd54b89e7bdad7074fd5ae3a0c3ae5b GIT binary patch literal 87462 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�_+-5R^YQ+3~0T?{! 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 UCi4kvByfFn;0Ca2jc3vS1F|34xBvhE literal 0 HcmV?d00001 diff --git a/anknotes/extra/graphics/evernote_metro.ico b/anknotes/extra/graphics/evernote_metro.ico new file mode 100644 index 0000000000000000000000000000000000000000..a0369d10d0e0f2f13942f4b5797ec97360dfcb1a GIT binary patch literal 370070 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 Tm=u}gAJ*$se9x#3JpcTEs*g8! literal 0 HcmV?d00001 diff --git a/anknotes/extra/graphics/evernote_metro.png b/anknotes/extra/graphics/evernote_metro.png new file mode 100644 index 0000000000000000000000000000000000000000..bcd57ac1dfaba95027fbc469f484c49feb70de5e GIT binary patch literal 5648 zcma)gcQoA3_x@`au}auzA%fK<%1Ve5qHMI)61}(RLiD=IVzuZ|g2bvJdaqGKNYo%m z)J2r&VwI@BeSiM`{qdc1=FFUX&YhV%_nCX2J2B5Sm2Z;YAqN29CQ?N~8vsCrDF}do z3Bv?b_5ooab5k+$1OTeu|2im*pNbg(=szPB<aPaKcC!7w?kWcNm#xc)iex>#6TfRx zi}5y;5tiu|`^h+k2F)^3ZVSO}*ty!|U~TgeN%J?qshPjIjW~XSu(bMBeSSBAWXLMc z)_KL`jSEYo4YI(b;HBtz+!}U-t!GB1qfqO_>jZytIPD;+GckE|u4|xYV4wvLZiok9 z`Zf|I0O6b<01W_g00093C;&hJfEf6{i(%@&wLc(AwFm1RTj#ujI<!j@lA@I=Mfis4 z6p{Bb&21BcAJUPv60L}`<nhH!NYPQAhg*dR(=fiTmui11w5Wnnn7eX+uML4=Xt}hw z$^-{%#cVezTw2?Vg4L=-6)g(4{=%<aF$t8YfEzqJ1ck%zHQ%~UbuGcgS+soRl)jHr zA9~x)lzuQ`7R>U}58=B@)<DU|_7{)coL#5(f0_(v$`Iq4r>g2Jj58033TE!C=g7!< zl)4mlj2%?KMI*w{z}=+sTIcqq5`m`=qWXo`Sfs!I3(-g@$WwuGhsgjd^lj1zWezXD zzH{xE@?Pz_y#3Hqg9{Pil8Id*RQ^}f%Y&BbOLq8YI<Ro0F;>^sq#$|gxS>;g#ezFs z@dO^2B^wnGe#BESbM~r-U#A<XaG%%`XdiErK0jXl_3nD*tHtoCL1m5e%YQn7QaH+> z?t}sRh01F^;cew=1lr%~xzVd{ot@*2XBS`Z75h3U6*xRy(k#Iw+H(#*udQ*I#!iX% zBtw_!!M)}uu6%*G8(SKfq=I=Ahu81(>x%k{f$z8Xk8v*_PI*6OW@b@_RzYEnt&44y zmdc`M?ea}QZMI5_zP2L_SbM9WC87$>qtI$<b_&p%>B*vYbp?luEq=5vp$CuOl%0M4 z`Cs*@H&XwDz_+#|w`sOpwNOi{jnqCD`e*hV6!4A}{A0VoqAt^W(QV}e?OQ4*ydrm8 z0kA3mnXm4Z#u=VW+Q)uS9i&b{lFX8(p7K&)i}9tx4+Pps@%s3+{W$zxhuHR??1Pc* z;%0Zv8rxgMXrONM=vCS27XL^uov2=8Lrzmi{Kp7j`sE3{%?YoAp?Ni>`*655Y=^w$ zp?VZ|ssg(nlFt!uL8GcloAupGm<AGgDH;PsAo<8NCPee@AvDgi7jjXbW!EVDc`iqU zS;!+VtsV>fou#PljlE)_A1&IdGCrBWVADflObwY#(@?j=O9(nE9ZR>K<-kQ(sZppt zlPKvY-PN*mWUp;lu>#%sA*e4R)6rLhWE1(7*IudxnvQLOn`OK>kLFLx?ct$GP5qNI z;$yqi{<MFE6qX^m23bD3GN^ghoj4w-#)Jc@MS{N7S8>51-nV`I99}kG=jX-8SRv;D zyh1;-heNC3AUi(pXyEY&chss#9n)wYANc_b5sm7bqCt&`RrA1p=D+n+IazqA+5Qnq zb)OrYAa);Ko0wadQulMRS2L(zaXpAIE+Qa-2U9*YkAVDf&t}WmcJd0`a`PqIV7k#w zAv~BqdQu6{^a?TCFsas3a;u5B9^II~Nkp1(yzNeEdeO-PF#)p2o9i|uUuutRcOq8E zyN=W3vGvlZ!@t|Z#b?JslUJP!xy;~d`b%oP&ZN>|#Lz)XR-8A*gnrv1eluchqK!p2 zjZEUK?22J*)RRtolHhTfmqCZRi^0J{Cmgg6cv-`vKqqY<^#UesMD-7Z=`Y<22Ta0` zqrG*jw{m{qjkKa{y-^eE*TIOdtFiKEiRzZ$%6TduJ3?fMGkoVQo>Zk%D}^>MCycZC z#y7=j+(e*%Ze95+$+8nSbvGkak$VC`+f6sGyUHSy)FsATgR9T}{W}S$nYt78n`!Vc zZuPd(v!`gura<fbRTn86n!uv_pdYo=skl-<^8if}(+d4}X>vWgpeGDTqW<w|uj` z@xgz)mbZ?*OK<Bc>h|SOc@4W4P2}r(y@u>h>|IAjNwqdXOH>LWVKu@oB*e`!wk2xQ zx1}w|3)x@LP?}FVNYi(H)SQ2QnyZyP;5XpZGR2ICk+*P<8ZP{C4g(D0uLLPr?K#iL z*vU)C!ETy99`}fwA9CM|6=!(TyJj5GOyBp!BL=LI%b)bF4JRg6APYVI6v!DZJ5sY* z%igsbR8T*w7VPjs@T@p2B}C%9N@U^3S8quFX^Lwp8W71^8~8McRnZt|HPp*tlHQ<- z`*gYDV}JNnS5~d_lY}SH=Lc6R*Y()j#HR_Q_j3kC>EhLo#3}DOY{=rMdaXIV+Xw0& zu9HHPUvWI+Snp{QFo*10P99!WpG^1Xu)VMEulZv8srUjJp&McN8{-ued~)g!k%O>z zy7+NpWs6fwDrUB&ZcC@<4=cbt6dUvCz(Wl=xo<B1xHDvZ7u8TUXnEanFRxIPoko== zkCTQYkMA{yZU&VOhJ!8{iu9sxqYScg99Z7)L81r0ru==Or+-DBtF>{YI&rgPuz@^? z0HxXij<@&6UVzAi(CQq|CRTqGR+Uv(tc@CiEbmsBQiJSXOsviu<1Mi@cMsUZG~GbN z8Ttos)lOTTZ=7^R_oKrpgJg)Cd*^Ls?==Z28%C3ngQ19M%$qs=`m&(_P7ZLi7J59D zn~=2fxq2A`X-L+$xCbO;NlX}|6P`nPOOTnDv@H1ymM&NA<V=yrimI(b7Te&tj0UnE znS5?!rog6*T#L8AR{$7zK?gH?o4CmqZGe80dWR03vDCBTXG(6_?G&{fNj)(pNh`;b zJb_W7Rha|ZL>mAFkLkzY9&hM4SV@+ju7PmsK#Oin9W^0~=hML$k5{&dDn8@LntS== z0`;#z(*1>+^d*2{J`)rnFf+tz@Q#s$@#JRSX{fv{gCiTd$dOJK>dMOq)KCzV>dA=) zfM{KGHxd=vLSqZFlgOd!`2Y~@3PE5Vc-0g?5?r7n&?Wh8+c|;{3i>Rsfd2ZRWTKZ} zuDI^jEtp3yiVY1N7avQ20L%2ie`~t=nxxcfQOXs9>*K}b0GK&Qn1)<Iu&ddZ(H7>R z`!F6Z%B>ADj!axsyt*q7v-{sB`%!JtK(h67qe$s?4tg|n`ENX<E<-Pfmr`>^DqG`f z-*+-eE8EznGU4!bL<!Q!rQqf=J#KD4#@6T^H1@#e=LD*Lz3%L42Ex*D(Rs&TTca<M zUefoY&5Xod%BHPwH#k}x6BWKPfIW0&-Md`A!<IqbN<AYh;c$)Ht<BM$(dRw>g)>Lj zl*kj?&iR$??ro2x8wH<+uT5)G2iKLI6+_XASy2g#;qf&qpJ$IcGlKBSe;8k0{JvoJ zO}&7Ct4VevzC<bYCnwYx7A<18=S`d|g&GCI0Otq4caa(ch`8E;sNt$F7xQp<8wJR) z(7TWdT&?P|zWbc(xIa-zI*9lDSZL%Y0Q%`jGUg{5rx<1x;?lqC&!auv#0IWbJm1J{ z@IT?uHp+i_F?T`xCue(~n5Uln$x2jlQfaS{Tw)o$)c~tY!4ai*0d<}G;KP`ShP>0i z3K_E7da9EU{l1v*$<|MDMBb!M^bh<nGX+7|3*Gl;31K_hppVJJS7a(RYvl>XC_mK^ z24qr7+$wi=d-a=32O00r;(RyH7)uQLy72=DOM5c4W)Tv%w2fPO`8EvmrrO;(;KMC& z(psLi$>23Oc_46OeO_wtR%0xi)Ru(w<43rgt@d85h$Vp8tl@M>a`h9iq{4?#HY~O7 zdT;1pb}Oi+<n-33x?eI2ri&I^f8&WcLq}@*2v2S3>OJNS0zg0<y#h^=8)**u`90!o zTl#7V_b`(^>|;NX`zh8P)EuD<j`M#2&)VbRGVeI$0$3@57Yw)|Tpns5M<E$VCWr2j zt8p7v(naf`BSVRr*(iJYfn8D}EsF0CrlS4F<l4YqA`d)SfIM<Jh<o#=a-<|5BkVtj zKKmbhO*^zw$)Q!i(dBuQz^TXqot(@1EBrZomuQ>3coQ`efOK*9qdE;!n)J9xa@V}X zq;UlRf&3lN0TIeDSQll&QD6tb#$-CQ_=XZ40-YJBeq<v0=LH{-^*yvYarT2e4A^t< z3gpMuB%2$UbU5ZYJl$Wup#jCw<QogPf1Tj81Kk~}Fin*JM?2LhP>GYR5(zCrh?~L6 zsN=Z|Z`9}8*VV;v#dqv>pyqjj>`t(kq)9KVt7VCnV^Kg-Jqp%E@4zI({(3nPBjnbk zCWJd8YL41ws~DQ+h<uT;pZF(@2g6C6p)~98^aX|6!F_}2>jsD7UIG__;_lC*57G&z zD$3*28eCY~JsZK~(dN_V{)b=Fw2AOVu4-UW+-gqzeYt8kJpfi;QP^9UY&qGGFq40$ z*iK4i9vIZTmH3X<SJnv+da=HByy$ulCCxtCafmX}e7`Y|^1KSV)50oG1}f5$eQx4t zlI_P|mtfJjI>k0}HOm<rEL3{>Za+~Ty`?kTa(>G?Us0*2E0k%Ez?XdNJrP2mhelbe zEs3k8-pM*$d51m1R7#NGDq7!O|1Am;FRkFX)y2XX1=P7fKJeHIjxN0KGC=cnmYjbn zcm!wCwG;hL@MDJqYW?DCNv{L<$ul3*0@wY%Z#nZiSA~o-h-4K;@KA*ye!OGHH(iDY zuUJ?$f1JIa*sgc-H~v?XtH4a!PjEdYTNL2Aw&GdEwt4n(yfMK4*e-Mz9%8ru-CNeP z9yL0)`{d=}Ui-cC1ce|wkDn8ypcmbLSW0((n$6ZoO&jXFm=Nq1%nkSbr)1yBrYrhR zsi9%K@xIwx3uhxtq!e++6kjdDK8N6n@1|*ve5NT)X&iKWQuy<Ap|*lXYhS!~WJm5- zZH-$_j#EnVaDD~atV8Qu^9v`FFv;x7QFx=kePKNtrq}=cG4P$)5lg2VX)TX0?GVxW zRv%SO1mE5#`9T%f`A5$b#8?!PUl5YdX>6fCqt7%sWt++TuWsta`A?=kX5HIxsvIne z^DKX^Rr;*(gLbKqnwi~mBR@m!z@Lob-M)pfBVw_A${Kx-3H<SU>*w+scgfucbcybX zJpIYBRh<qf-ZO;YwEoQ0N1HpD3N;ee?W#rcY;p=F4+%A#uT~_DTD(S%y>{^zf7gI# zUEZ~O{FNhwM<Y4Y&U<!NM$xG@X_s!~YMSNGZy>T?1fHu3%|r*GTk?}i)9n^`QU+p& zCYjE_`XO5c|L=_0b!3AoUrDri;G*8c$-*`Kk+5y4A4g+fV#7^KP19Snc+!|Brv~cv zhQxyX7%%@<No|j;Wv7jP`PV`rBM^<@?>ega^H){d3Bqn1VTJ6A%sB7Z`*Hnx^p|L< zPDtD7UIY2lx!~kqW5N)q0Yg#Hbmj=sJjq_5vyZnwsONlUQA)}$<2rKyX}<3;9DDFj zP$n)kpNvlGEor^JRdaVhim}Yji0;$t&_TAqB|)<lK@`00Q=Xgs-QZYi>s%-rDGi#g z!H#0Eu{~yNRYSixu!5y9Amdvy_DR$4#nIy`eCrGhz;P~mb+D0wgyOJ~0J>OIG9lIE zZ2=jv!a!1S)B9mODen#x7z(9xvGd=2h|t0$X%8-on(c0PGWWQhiyxo3j&vrrZ@*`j zBm;GM3zzEfkLzcOIu0!)VQ-3ROkmcoQ|O_v2V?viTjajIvb&w5F$?gwb_;#l%Lkb> z6d(+#q87a)|DF&ktc3WblyCpBqJ9(qx{tLqq-%PxMxmx|v*qQp@(lh5&pIh0F9mEx z=*aTPh%29;?3D(yOSPo_vax2s;c@cmYYIQ8e;@$ilp_*qVb;sT?j8G=V~A&6B%m(Q z*8-FL9=mi%JKjKRD4=d_))CF~VfrZKCa8;hK>O|RpT6|mr5<BBDB7a@+w8z75p(8d zlf!tX#-YugIRp?&E?2GLu{}Ke896r}M_@jha)4V*DgKeOZ`G{QruDfHGj&B%A~qDj z(NJio*JcGrcEIdFpOc|0{Szxr5d^VwQ^;B!R89Ev{xGg1#-}4Ygb?_}%oe0|+yYpN zNvUZNkxzn%QnKqTyo2<?7^0!ICYO3vFj_6u&}mJlVXu8(sLZ=mJ>ODH{&-jDDPV>8 z!g}dC**+BDrou=?O*O3;0R;AbRE^(>b&n24L>pIH3qOkb6dR=ofSS1`o-_6|y}&NZ z8sJrF!%G?{bI^e0owbZtB4+MUE@Hxt<BaMRp5<g<fQRGTFFpc6oA#q11}MAAcuVb& zUgZp6;KuiRaadqW#A*EKA7#;0e_{lbU5+EE63%E7qVVCT>9C~}A&+4IbY4-*09};D zJyC-PX}Sd~CJR_~2uzw%Rb*DyYGT#)&g+b=261_yi*fRgcu2rih~W;o>;2%2?jQN* zo(O{2#MN)}#QrKGKK0(~8KZyF;afWIz4rrL`5-VAhC%_OG}K96CADLgoiN_>jhZX} z%%f|`QbfrWI~r|dP+c+JxJh6aBH^UG7uFA?+Ynl*m^b$e2fTvhu{CX?wqz^Ra%Vl} z!Ml_cAivOAS!|(t!oMvBjkDo@CyLV#*pBi3;L2}k5yoRwI4eR0Lp6a(N7^i@7~W4- z+^ANn<itG3yirTDiytmT>ZneZ+wH5I6idUw1aIz))Ts-Cvh#;k#_aY5!;49&5P+NB zdvj&*dZkM}_L_-sIf7KaK||N!7{K%)f!X~iZwV}r-n&8oyN;}n(?1%;&gw`(<$n4- zWo=M@XPJ5D<mU9rUKD^kySw$z*=3=fEWL#K-L)ED6gfzuZ`YZ}KPH`1zW$0Gu=vc~ zZ7h#)ynG7Ohh?nK?IS^Y*HAQ@o?ZWLFBAqnZuecwiSQnRqHhNV?jA|0vC#$y1^b@m z5c~cod%ro`^u3j1+K^{XAkbsCGh*^I6u4L>1i~EigPa>_M$x2;GCt{1Tfqn*^gy!* z&Q==Gom&gps@@q{^W6-&_uW&o{d7`HfwjudW^9z?2ZIxHMM%>UTLh}nodjoX;$S<H zeiR{f8uD>Zygk5fKPh)qtP;3b9U1R;dN_5^`0LPU%k{KnS&~?VPk()0$Hl~2*TmmL zn#DSP@#o_0Wi&N0R7~7<F;8p5b!dhw<?G7z)@2Gy1*bQGSebQ<DLgKN$|V`5crX+S z#dHw3v+DG!$Q5l5wxcMIldF}QZI5jy0<er^|D_#6fY6()RPXr2n=L4%;u{b`y_khh z{9-lc*b%Cg+uOUe%Y(r`^WxfnYX5)z=mg{F{=Yw>WP{g4k;H!1l14&UfY6YNnhKQ& H%dr0k5Hf<% literal 0 HcmV?d00001 diff --git a/anknotes/extra/graphics/evernote_metro_reflected.ico b/anknotes/extra/graphics/evernote_metro_reflected.ico new file mode 100644 index 0000000000000000000000000000000000000000..6251c63f95a05f0a44cdaf2a1f8655a7053bd84b GIT binary patch literal 370070 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 W@0P1={+4%AeVgQ8CtBT;PW=D+?~>;L literal 0 HcmV?d00001 diff --git a/anknotes/extra/graphics/evernote_metro_reflected.png b/anknotes/extra/graphics/evernote_metro_reflected.png new file mode 100644 index 0000000000000000000000000000000000000000..982eabcef8764d1d871e12ebc9049e4758cddb2c GIT binary patch literal 8452 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 DTSRbY literal 0 HcmV?d00001 diff --git a/anknotes/extra/graphics/evernote_web.ico b/anknotes/extra/graphics/evernote_web.ico new file mode 100644 index 0000000000000000000000000000000000000000..f1e545a1c9c476e86ee61eafbc2be1f52629ac10 GIT binary patch literal 370070 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%0ANT!m&mYh85&jwfg?}(qc@^gK!AjS% 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 F{(tBFe^3Ab literal 0 HcmV?d00001 diff --git a/anknotes/extra/graphics/evernote_web.png b/anknotes/extra/graphics/evernote_web.png new file mode 100644 index 0000000000000000000000000000000000000000..f44829560afbdbb6da1a1e1e652c876bc54b7c1a GIT binary patch literal 18786 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 WWQ+Q#*@VRa0000<MNUMnLSTY_E!<lG literal 0 HcmV?d00001 diff --git a/anknotes/menu.py b/anknotes/menu.py new file mode 100644 index 0000000..90d89dc --- /dev/null +++ b/anknotes/menu.py @@ -0,0 +1,309 @@ +# -*- coding: utf-8 -*- +# Python Imports +from subprocess import * + +try: + from pysqlite2 import dbapi2 as sqlite +except ImportError: + from sqlite3 import dbapi2 as sqlite + +# Anknotes Shared Imports +from anknotes.shared import * +from anknotes.constants import * + +# Anknotes Main Imports +from anknotes.Controller import Controller + +# Anki Imports +from aqt.qt import SIGNAL, QMenu, QAction +from aqt import mw +from aqt.utils import getText +# from anki.storage import Collection + +DEBUG_RAISE_API_ERRORS = False +# log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') + + +# noinspection PyTypeChecker +def anknotes_setup_menu(): + menu_items = [ + [u"&Anknotes", + [ + ["&Import from Evernote", import_from_evernote], + ["&Enable Auto Import On Profile Load", {'action': anknotes_menu_auto_import_changed, 'checkable': True}], + ["Note &Validation", + [ + ["Validate &And Upload Pending Notes", validate_pending_notes], + ["SEPARATOR", None], + ["&Validate Pending Notes", lambda: validate_pending_notes(True, False)], + ["&Upload Validated Notes", upload_validated_notes] + ] + ], + ["Process &See Also Footer Links [Power Users Only!]", + [ + ["Complete All &Steps", see_also], + ["SEPARATOR", None], + ["Step &1: Process Anki Notes Without See Also Field", lambda: see_also(1)], + ["Step &2: Extract Links from TOC", lambda: see_also(2)], + ["SEPARATOR", None], + ["Step &3: Create Auto TOC Evernote Notes", lambda: see_also(3)], + ["Step &4: Validate and Upload Auto TOC Notes", lambda: see_also(4)], + ["Step &5: Rebuild TOC/Outline Link Database", lambda: see_also(6)], + ["SEPARATOR", None], + ["Step &6: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(7)], + ["Step &7: Update See Also Footer In Evernote Notes", lambda: see_also(8)], + ["Step &8: Validate and Upload Modified Evernote Notes", lambda: see_also(9)], + ["SEPARATOR", None], + ["Step &9: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(10)] + ] + ], + ["&Maintenance Tasks", + [ + ["Find &Deleted Notes", find_deleted_notes], + ["Res&ync with Local DB", resync_with_local_db], + ["Update Evernote &Ancillary Data", update_ancillary_data] + ] + ] + + ] + ] + ] + add_menu_items(menu_items) + + +def add_menu_items(menu_items, parent=None): + if not parent: parent = mw.form.menubar + for title, action in menu_items: + if title == "SEPARATOR": + parent.addSeparator() + elif isinstance(action, list): + menu = QMenu(_(title), parent) + parent.insertMenu(mw.form.menuTools.menuAction(), menu) + add_menu_items(action, menu) + else: + checkable = False + if isinstance(action, dict): + options = action + action = options['action'] + if 'checkable' in options: + checkable = options['checkable'] + menu_action = QAction(_(title), mw, checkable=checkable) + parent.addAction(menu_action) + parent.connect(menu_action, SIGNAL("triggered()"), action) + if checkable: + anknotes_checkable_menu_items[title] = menu_action + + +def anknotes_menu_auto_import_changed(): + title = "&Enable Auto Import On Profile Load" + doAutoImport = anknotes_checkable_menu_items[title].isChecked() + mw.col.conf[ + SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', '')] = doAutoImport + mw.col.setMod() + mw.col.save() + # import_timer_toggle() + + +def anknotes_load_menu_settings(): + global anknotes_checkable_menu_items + for title, menu_action in anknotes_checkable_menu_items.items(): + menu_action.setChecked(mw.col.conf.get( + SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False)) + + +def import_from_evernote_manual_metadata(guids=None): + if not guids: + guids = find_evernote_guids(file(ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES, 'r').read()) + log("Manually downloading %d Notes" % len(guids)) + controller = Controller() + controller.evernote.initialize_note_store() + controller.forceAutoPage = True + controller.currentPage = 1 + controller.ManualGUIDs = guids + controller.proceed() + + +def import_from_evernote(auto_page_callback=None): + controller = Controller() + controller.evernote.initialize_note_store() + controller.auto_page_callback = auto_page_callback + if auto_page_callback: + controller.forceAutoPage = True + controller.currentPage = 1 + else: + controller.forceAutoPage = False + controller.currentPage = mw.col.conf.get(SETTINGS.EVERNOTE_PAGINATION_CURRENT_PAGE, 1) + controller.proceed() + + +def upload_validated_notes(automated=False): + controller = Controller() + controller.upload_validated_notes(automated) + + +def find_deleted_notes(automated=False): + if not automated and False: + showInfo("""In order for this to work, you must create a 'Table of Contents' Note using the Evernote desktop application. Include all notes that you want to sync with Anki. + +Export this note to the following path: '%s'. + +Press Okay to save and close your Anki collection, open the command-line deleted notes detection tool, and then re-open your Anki collection. + +Once the command line tool is done running, you will get a summary of the results, and will be prompted to delete Anki Orphan Notes or download Missing Evernote Notes""" % ANKNOTES.TABLE_OF_CONTENTS_ENEX, + richText=True) + + # mw.col.save() + # if not automated: + # mw.unloadCollection() + # else: + # mw.col.close() + # handle = Popen(['python',ANKNOTES.FIND_DELETED_NOTES_SCRIPT], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) + # stdoutdata, stderrdata = handle.communicate() + # err = ("ERROR: {%s}\n\n" % stderrdata) if stderrdata else '' + # stdoutdata = re.sub(' +', ' ', stdoutdata) + from anknotes import find_deleted_notes + returnedData = find_deleted_notes.do_find_deleted_notes() + lines = returnedData['Summary'] + info = tableify_lines(lines, '#|Type|Info') + # info = '<table><tr class=tr0><td class=t1>#</td><td class=t2>Type</td><td class=t3></td></tr>%s</table>' % '\n'.join(lines) + # info = info.replace('\n', '\n<BR>').replace(' ', '    ') + anknotes_dels = returnedData['AnknotesOrphans'] + anknotes_dels_count = len(anknotes_dels) + anki_dels = returnedData['AnkiOrphans'] + anki_dels_count = len(anki_dels) + missing_evernote_notes = returnedData['MissingEvernoteNotes'] + missing_evernote_notes_count = len(missing_evernote_notes) + showInfo(info, richText=True, minWidth=600) + db_changed = False + if anknotes_dels_count > 0: + code = \ + getText("Please enter code 'ANKNOTES_DEL_%d' to delete your orphan Anknotes DB note(s)" % anknotes_dels_count)[ + 0] + if code == 'ANKNOTES_DEL_%d' % anknotes_dels_count: + ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, [[x] for x in anknotes_dels]) + ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", + [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anknotes_dels]) + db_changed = True + show_tooltip("Deleted all %d Orphan Anknotes DB Notes" % count, 5000, 3000) + if anki_dels_count > 0: + code = getText("Please enter code 'ANKI_DEL_%d' to delete your orphan Anki note(s)" % anki_dels_count)[0] + if code == 'ANKI_DEL_%d' % anki_dels_count: + ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", + [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anki_dels]) + db_changed = True + show_tooltip("Deleted all %d Orphan Anki Notes" % count, 5000, 3000) + if db_changed: + ankDB().commit() + if missing_evernote_notes_count > 0: + evernote_confirm = "Would you like to import %d missing Evernote Notes?<BR><BR><a href='%s'>Click to view results</a>" % ( + missing_evernote_notes_count, + convert_filename_to_local_link(get_log_full_path(ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES))) + ret = showInfo(evernote_confirm, cancelButton=True, richText=True) + if ret: + import_from_evernote_manual_metadata(missing_evernote_notes) + + +def validate_pending_notes(showAlerts=True, uploadAfterValidation=True, callback=None): + mw.unloadCollection() + if showAlerts: + showInfo("""Press Okay to save and close your Anki collection, open the command-line note validation tool, and then re-open your Anki collection.%s + +Anki will be unresponsive until the validation tool completes. This will take at least 45 seconds. The tool's output will be displayed upon completion. """ + % ( + ' You will be given the option of uploading successfully validated notes once your Anki collection is reopened.' if uploadAfterValidation else '')) + handle = Popen(['python', ANKNOTES.VALIDATION_SCRIPT], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) + stdoutdata, stderrdata = handle.communicate() + stdoutdata = re.sub(' +', ' ', stdoutdata) + info = ("ERROR: {%s}<HR>" % stderrdata) if stderrdata else '' + allowUpload = True + if showAlerts: + tds = [[str(count), '<a href="%s">VIEW %s VALIDATIONS LOG</a>' % (fn, key.upper())] for key, fn, count in [ + [key, get_log_full_path(key, as_url_link=True), int(re.search(r'CHECKING +(\d{1,3}) +' + key.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1))] + for key in ['Pending', 'Successful', 'Failed']] if count > 0] + if not tds: + show_tooltip("No notes found in the validation queue.") + allowUpload = False + else: + info += tableify_lines(tds, '#|Result') + successful = int(re.search(r'CHECKING +(\d{1,3}) +' + 'Successful'.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1)) + allowUpload = (uploadAfterValidation and successful > 0) + allowUpload = allowUpload & showInfo("Completed: %s<BR>%s" % ( + 'Press Okay to begin uploading %d successfully validated note(s) to the Evernote Servers' % successful if (uploadAfterValidation and successful > 0) else '', + info), cancelButton=(successful > 0), richText=True) + + + # mw.col.reopen() + # mw.col.load() + + if callback is None and allowUpload: + callback = upload_validated_notes + external_tool_callback_timer(callback) + + +def reopen_collection(callback=None): + # mw.setupProfile() + mw.loadCollection() + ankDB(True) + if callback: callback() + + +def external_tool_callback_timer(callback=None): + mw.progress.timer(3000, lambda: reopen_collection(callback), False) + + +def see_also(steps=None, showAlerts=None, validationComplete=False): + controller = Controller() + if not steps: steps = range(1, 10) + if isinstance(steps, int): steps = [steps] + multipleSteps = (len(steps) > 1) + if showAlerts is None: showAlerts = not multipleSteps + remaining_steps=steps + if 1 in steps: + # Should be unnecessary once See Also algorithms are finalized + log(" > See Also: Step 1: Processing Un Added See Also Notes") + controller.process_unadded_see_also_notes() + if 2 in steps: + log(" > See Also: Step 2: Extracting Links from TOC") + controller.anki.extract_links_from_toc() + if 3 in steps: + log(" > See Also: Step 3: Creating Auto TOC Evernote Notes") + controller.create_auto_toc() + if 4 in steps: + if validationComplete: + log(" > See Also: Step 4: Validate and Upload Auto TOC Notes: Upload Validating Notes") + upload_validated_notes(multipleSteps) + else: + steps = [-4] + if 5 in steps: + log(" > See Also: Step 5: Inserting TOC/Outline Links Into Anki Notes' See Also Field") + controller.anki.insert_toc_into_see_also() + if 6 in steps: + log(" > See Also: Step 6: Update See Also Footer In Evernote Notes") + if 7 in steps: + if validationComplete: + log(" > See Also: Step 7: Validate and Upload Modified Notes: Upload Validating Notes") + upload_validated_notes(multipleSteps) + else: + steps = [-7] + if 8 in steps: + log(" > See Also: Step 8: Inserting TOC/Outline Contents Into Anki Notes") + controller.anki.insert_toc_and_outline_contents_into_notes() + + do_validation = steps[0]*-1 + if do_validation>0: + log(" > See Also: Step %d: Validate and Upload %s Notes: Validating Notes" % (do_validation, {4: 'Auto TOC', 7: 'Modified Evernote'}[do_validation])) + remaining_steps = remaining_steps[remaining_steps.index(do_validation)+validationComplete and 1 or 0:] + validate_pending_notes(showAlerts, callback=lambda: see_also(remaining_steps, False, True)) + +def update_ancillary_data(): + controller = Controller() + controller.evernote.initialize_note_store() + controller.update_ancillary_data() + + +def resync_with_local_db(): + controller = Controller() + controller.resync_with_local_db() + + +anknotes_checkable_menu_items = {} diff --git a/anknotes/oauth2/__init__.py b/anknotes/oauth2/__init__.py index 088e107..a2745c1 100644 --- a/anknotes/oauth2/__init__.py +++ b/anknotes/oauth2/__init__.py @@ -29,6 +29,7 @@ import urlparse import hmac import binascii + import httplib2 try: diff --git a/anknotes/settings.py b/anknotes/settings.py new file mode 100644 index 0000000..46c0128 --- /dev/null +++ b/anknotes/settings.py @@ -0,0 +1,695 @@ +# -*- coding: utf-8 -*- + +### Anknotes Shared Imports +from anknotes.shared import * +from anknotes.graphics import * + +### Anki Imports +try: + import anki + import aqt + from aqt.preferences import Preferences + from aqt.utils import getText, openLink, getOnlyText + from aqt.qt import QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QGroupBox, SIGNAL, QCheckBox, \ + QComboBox, QSpacerItem, QSizePolicy, QWidget, QSpinBox, QFormLayout, QGridLayout, QFrame, QPalette, \ + QRect, QStackedLayout, QDateEdit, QDateTimeEdit, QTimeEdit, QDate, QDateTime, QTime, QPushButton, QIcon, \ + QMessageBox, QPixmap + from aqt import mw +except: + pass + + +class EvernoteQueryLocationValueQSpinBox(QSpinBox): + __prefix = "" + + def setPrefix(self, text): + self.__prefix = text + + def prefix(self): + return self.__prefix + + def valueFromText(self, text): + if text == self.prefix(): + return 0 + return text[len(self.prefix()) + 1:] + + def textFromValue(self, value): + if value == 0: + return self.prefix() + return self.prefix() + "-" + str(value) + + +def setup_evernote(self): + global icoEvernoteWeb + global imgEvernoteWeb + global evernote_default_tag + global evernote_query_any + global evernote_query_use_tags + global evernote_query_tags + global evernote_query_use_notebook + global evernote_query_notebook + global evernote_query_use_note_title + global evernote_query_note_title + global evernote_query_use_search_terms + global evernote_query_search_terms + global evernote_query_use_last_updated + global evernote_query_last_updated_type + global evernote_query_last_updated_value_stacked_layout + global evernote_query_last_updated_value_relative_spinner + global evernote_query_last_updated_value_absolute_date + global evernote_query_last_updated_value_absolute_datetime + global evernote_query_last_updated_value_absolute_time + global default_anki_deck + global anki_deck_evernote_notebook_integration + global keep_evernote_tags + global delete_evernote_query_tags + global evernote_pagination_current_page_spinner + global evernote_pagination_auto_paging + + widget = QWidget() + layout = QVBoxLayout() + + + ########################## QUERY ########################## + group = QGroupBox("EVERNOTE SEARCH OPTIONS:") + group.setStyleSheet('QGroupBox{ font-size: 10px; font-weight: bold; color: rgb(105, 170, 53);}') + form = QFormLayout() + + form.addRow(gen_qt_hr()) + + # Evernote Query: Match Any Terms + evernote_query_any = QCheckBox(" Match Any Terms", self) + evernote_query_any.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_ANY, True)) + evernote_query_any.stateChanged.connect(update_evernote_query_any) + evernote_query_any.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + button_show_generated_evernote_query = QPushButton(icoEvernoteWeb, "Show Full Query", self) + button_show_generated_evernote_query.setAutoDefault(False) + button_show_generated_evernote_query.connect(button_show_generated_evernote_query, + SIGNAL("clicked()"), + handle_show_generated_evernote_query) + + + # Add Form Row for Match Any Terms + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_any) + hbox.addWidget(button_show_generated_evernote_query) + form.addRow("<b>Search Query:</b>", hbox) + + # Evernote Query: Tags + evernote_query_tags = QLineEdit() + evernote_query_tags.setText( + mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE)) + evernote_query_tags.connect(evernote_query_tags, + SIGNAL("textEdited(QString)"), + update_evernote_query_tags) + + # Evernote Query: Use Tags + evernote_query_use_tags = QCheckBox(" ", self) + evernote_query_use_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_TAGS, True)) + evernote_query_use_tags.stateChanged.connect(update_evernote_query_use_tags) + + # Add Form Row for Tags + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_tags) + hbox.addWidget(evernote_query_tags) + form.addRow("Tags:", hbox) + + # Evernote Query: Search Terms + evernote_query_search_terms = QLineEdit() + evernote_query_search_terms.setText(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_SEARCH_TERMS, "")) + evernote_query_search_terms.connect(evernote_query_search_terms, + SIGNAL("textEdited(QString)"), + update_evernote_query_search_terms) + + # Evernote Query: Use Search Terms + evernote_query_use_search_terms = QCheckBox(" ", self) + evernote_query_use_search_terms.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_SEARCH_TERMS, False)) + evernote_query_use_search_terms.stateChanged.connect(update_evernote_query_use_search_terms) + + # Add Form Row for Search Terms + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_search_terms) + hbox.addWidget(evernote_query_search_terms) + form.addRow("Search Terms:", hbox) + + # Evernote Query: Notebook + evernote_query_notebook = QLineEdit() + evernote_query_notebook.setText( + mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_NOTEBOOK, SETTINGS.EVERNOTE_QUERY_NOTEBOOK_DEFAULT_VALUE)) + evernote_query_notebook.connect(evernote_query_notebook, + SIGNAL("textEdited(QString)"), + update_evernote_query_notebook) + + # Evernote Query: Use Notebook + evernote_query_use_notebook = QCheckBox(" ", self) + evernote_query_use_notebook.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTEBOOK, False)) + evernote_query_use_notebook.stateChanged.connect(update_evernote_query_use_notebook) + + # Add Form Row for Notebook + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_notebook) + hbox.addWidget(evernote_query_notebook) + form.addRow("Notebook:", hbox) + + # Evernote Query: Note Title + evernote_query_note_title = QLineEdit() + evernote_query_note_title.setText(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_NOTE_TITLE, "")) + evernote_query_note_title.connect(evernote_query_note_title, + SIGNAL("textEdited(QString)"), + update_evernote_query_note_title) + + # Evernote Query: Use Note Title + evernote_query_use_note_title = QCheckBox(" ", self) + evernote_query_use_note_title.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTE_TITLE, False)) + evernote_query_use_note_title.stateChanged.connect(update_evernote_query_use_note_title) + + # Add Form Row for Note Title + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_note_title) + hbox.addWidget(evernote_query_note_title) + form.addRow("Note Title:", hbox) + + # Evernote Query: Last Updated Type + evernote_query_last_updated_type = QComboBox() + evernote_query_last_updated_type.setStyleSheet(' QComboBox { color: rgb(45, 79, 201); font-weight: bold; } ') + evernote_query_last_updated_type.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + evernote_query_last_updated_type.addItems([u"Δ Day", u"Δ Week", u"Δ Month", u"Δ Year", "Date", "+ Time"]) + evernote_query_last_updated_type.setCurrentIndex(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_TYPE, + EvernoteQueryLocationType.RelativeDay)) + evernote_query_last_updated_type.activated.connect(update_evernote_query_last_updated_type) + + + # Evernote Query: Last Updated Type: Relative Date + evernote_query_last_updated_value_relative_spinner = EvernoteQueryLocationValueQSpinBox() + evernote_query_last_updated_value_relative_spinner.setVisible(False) + evernote_query_last_updated_value_relative_spinner.setStyleSheet( + " QSpinBox, EvernoteQueryLocationValueQSpinBox { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_query_last_updated_value_relative_spinner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_relative_spinner.connect(evernote_query_last_updated_value_relative_spinner, + SIGNAL("valueChanged(int)"), + update_evernote_query_last_updated_value_relative_spinner) + + # Evernote Query: Last Updated Type: Absolute Date + evernote_query_last_updated_value_absolute_date = QDateEdit() + evernote_query_last_updated_value_absolute_date.setDisplayFormat('M/d/yy') + evernote_query_last_updated_value_absolute_date.setCalendarPopup(True) + evernote_query_last_updated_value_absolute_date.setVisible(False) + evernote_query_last_updated_value_absolute_date.setStyleSheet( + "QDateEdit { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_query_last_updated_value_absolute_date.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_absolute_date.connect(evernote_query_last_updated_value_absolute_date, + SIGNAL("dateChanged(QDate)"), + update_evernote_query_last_updated_value_absolute_date) + + # Evernote Query: Last Updated Type: Absolute DateTime + evernote_query_last_updated_value_absolute_datetime = QDateTimeEdit() + evernote_query_last_updated_value_absolute_datetime.setDisplayFormat('M/d/yy h:mm AP') + evernote_query_last_updated_value_absolute_datetime.setCalendarPopup(True) + evernote_query_last_updated_value_absolute_datetime.setVisible(False) + evernote_query_last_updated_value_absolute_datetime.setStyleSheet( + "QDateTimeEdit { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_absolute_datetime.connect(evernote_query_last_updated_value_absolute_datetime, + SIGNAL("dateTimeChanged(QDateTime)"), + update_evernote_query_last_updated_value_absolute_datetime) + + + + # Evernote Query: Last Updated Type: Absolute Time + evernote_query_last_updated_value_absolute_time = QTimeEdit() + evernote_query_last_updated_value_absolute_time.setDisplayFormat('h:mm AP') + evernote_query_last_updated_value_absolute_time.setVisible(False) + evernote_query_last_updated_value_absolute_time.setStyleSheet( + "QTimeEdit { font-weight: bold; color: rgb(143, 0, 30); } ") + evernote_query_last_updated_value_absolute_time.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_absolute_time.connect(evernote_query_last_updated_value_absolute_time, + SIGNAL("timeChanged(QTime)"), + update_evernote_query_last_updated_value_absolute_time) + + hbox_datetime = QHBoxLayout() + hbox_datetime.addWidget(evernote_query_last_updated_value_absolute_date) + hbox_datetime.addWidget(evernote_query_last_updated_value_absolute_time) + + # Evernote Query: Last Updated Type + evernote_query_last_updated_value_stacked_layout = QStackedLayout() + evernote_query_last_updated_value_stacked_layout.addWidget(evernote_query_last_updated_value_relative_spinner) + evernote_query_last_updated_value_stacked_layout.addItem(hbox_datetime) + + # Evernote Query: Use Last Updated + evernote_query_use_last_updated = QCheckBox(" ", self) + evernote_query_use_last_updated.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + evernote_query_use_last_updated.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_LAST_UPDATED, False)) + evernote_query_use_last_updated.stateChanged.connect(update_evernote_query_use_last_updated) + + # Add Form Row for Last Updated + hbox = QHBoxLayout() + label = QLabel("Last Updated: ") + label.setMinimumWidth(100) + hbox.addWidget(evernote_query_use_last_updated) + hbox.addWidget(evernote_query_last_updated_type) + hbox.addWidget(evernote_query_last_updated_value_relative_spinner) + hbox.addWidget(evernote_query_last_updated_value_absolute_date) + hbox.addWidget(evernote_query_last_updated_value_absolute_time) + form.addRow(label, hbox) + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + ############################ PAGINATION ########################## + # Evernote Pagination: Current Page + evernote_pagination_current_page_spinner = QSpinBox() + evernote_pagination_current_page_spinner.setStyleSheet("QSpinBox { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_pagination_current_page_spinner.setPrefix("PAGE: ") + evernote_pagination_current_page_spinner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_pagination_current_page_spinner.setValue(mw.col.conf.get(SETTINGS.EVERNOTE_PAGINATION_CURRENT_PAGE, 1)) + evernote_pagination_current_page_spinner.connect(evernote_pagination_current_page_spinner, + SIGNAL("valueChanged(int)"), + update_evernote_pagination_current_page_spinner) + + # Evernote Pagination: Auto Paging + evernote_pagination_auto_paging = QCheckBox(" Automate", self) + evernote_pagination_auto_paging.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + evernote_pagination_auto_paging.setFixedWidth(105) + evernote_pagination_auto_paging.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_AUTO_PAGING, True)) + evernote_pagination_auto_paging.stateChanged.connect(update_evernote_pagination_auto_paging) + + hbox = QHBoxLayout() + hbox.addWidget(evernote_pagination_auto_paging) + hbox.addWidget(evernote_pagination_current_page_spinner) + + # Add Form Row for Evernote Pagination + form.addRow("<b>Pagination:</b>", hbox) + + # Add Query Form to Group Box + group.setLayout(form) + + # Add Query Group Box to Main Layout + layout.addWidget(group) + + ########################## DECK ########################## + # label = QLabel("<span style='background-color: #bf0060;'><B><U>ANKI NOTE OPTIONS</U>:</B></span>") + group = QGroupBox("ANKI NOTE OPTIONS:") + group.setStyleSheet('QGroupBox{ font-size: 10px; font-weight: bold; color: rgb(105, 170, 53);}') + form = QFormLayout() + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + # Default Anki Deck + default_anki_deck = QLineEdit() + default_anki_deck.setText(mw.col.conf.get(SETTINGS.DEFAULT_ANKI_DECK, SETTINGS.DEFAULT_ANKI_DECK_DEFAULT_VALUE)) + default_anki_deck.connect(default_anki_deck, SIGNAL("textEdited(QString)"), update_default_anki_deck) + + # Add Form Row for Default Anki Deck + hbox = QHBoxLayout() + hbox.insertSpacing(0, 33) + hbox.addWidget(default_anki_deck) + label_deck = QLabel("<b>Anki Deck:</b>") + label_deck.setMinimumWidth(100) + form.addRow(label_deck, hbox) + + # Evernote Notebook Integration + anki_deck_evernote_notebook_integration = QCheckBox(" Append Evernote Notebook", self) + anki_deck_evernote_notebook_integration.setChecked( + mw.col.conf.get(SETTINGS.ANKI_DECK_EVERNOTE_NOTEBOOK_INTEGRATION, True)) + anki_deck_evernote_notebook_integration.stateChanged.connect(update_anki_deck_evernote_notebook_integration) + + # Change Visibility of Deck Options + update_anki_deck_visibilities() + + # Add Form Row for Evernote Notebook Integration + label_deck = QLabel("Evernote Notebook:") + label_deck.setMinimumWidth(100) + form.addRow("", anki_deck_evernote_notebook_integration) + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + ############################ TAGS ########################## + # Keep Evernote Tags + keep_evernote_tags = QCheckBox(" Save To Anki Note", self) + keep_evernote_tags.setChecked( + mw.col.conf.get(SETTINGS.KEEP_EVERNOTE_TAGS, SETTINGS.KEEP_EVERNOTE_TAGS_DEFAULT_VALUE)) + keep_evernote_tags.stateChanged.connect(update_keep_evernote_tags) + + # Evernote Tags: Tags to Delete + evernote_tags_to_delete = QLineEdit() + evernote_tags_to_delete.setText(mw.col.conf.get(SETTINGS.EVERNOTE_TAGS_TO_DELETE, "")) + evernote_tags_to_delete.connect(evernote_tags_to_delete, + SIGNAL("textEdited(QString)"), + update_evernote_tags_to_delete) + + # Delete Tags To Import + delete_evernote_query_tags = QCheckBox(" Also Delete Search Tags", self) + delete_evernote_query_tags.setChecked(mw.col.conf.get(SETTINGS.DELETE_EVERNOTE_TAGS_TO_IMPORT, True)) + delete_evernote_query_tags.stateChanged.connect(update_delete_evernote_query_tags) + + # Add Form Row for Evernote Tag Options + label = QLabel("<b>Evernote Tags:</b>") + label.setMinimumWidth(100) + form.addRow(label, keep_evernote_tags) + hbox = QHBoxLayout() + hbox.insertSpacing(0, 33) + hbox.addWidget(evernote_tags_to_delete) + form.addRow("Tags to Delete:", hbox) + form.addRow(" ", delete_evernote_query_tags) + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + ############################ NOTE UPDATING ########################## + # Note Update Method + update_existing_notes = QComboBox() + update_existing_notes.setStyleSheet( + ' QComboBox { color: #3b679e; font-weight: bold; } QComboBoxItem { color: #A40F2D; font-weight: bold; } ') + update_existing_notes.addItems(["Ignore Existing Notes", "Update In-Place", + "Delete and Re-Add"]) + update_existing_notes.setCurrentIndex(mw.col.conf.get(SETTINGS.UPDATE_EXISTING_NOTES, + UpdateExistingNotes.UpdateNotesInPlace)) + update_existing_notes.activated.connect(update_update_existing_notes) + + # Add Form Row for Note Update Method + hbox = QHBoxLayout() + hbox.insertSpacing(0, 33) + hbox.addWidget(update_existing_notes) + form.addRow("<b>Note Updating:</b>", hbox) + + # Add Note Update Method Form to Group Box + group.setLayout(form) + + # Add Note Update Method Group Box to Main Layout + layout.addWidget(group) + + # Update Visibilities of Query Options + evernote_query_text_changed() + update_evernote_query_visibilities() + + + # Vertical Spacer + vertical_spacer = QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) + layout.addItem(vertical_spacer) + + # Parent Widget + widget.setLayout(layout) + + # New Tab + self.form.tabWidget.addTab(widget, "Anknotes") + + +def gen_qt_hr(): + vbox = QVBoxLayout() + hr = QFrame() + hr.setAutoFillBackground(True) + hr.setFrameShape(QFrame.HLine) + hr.setStyleSheet("QFrame { background-color: #0060bf; color: #0060bf; }") + hr.setFixedHeight(2) + vbox.addWidget(hr) + vbox.addSpacing(4) + return vbox + + +def update_anki_deck_visibilities(): + if not default_anki_deck.text(): + anki_deck_evernote_notebook_integration.setChecked(True) + anki_deck_evernote_notebook_integration.setEnabled(False) + else: + anki_deck_evernote_notebook_integration.setEnabled(True) + anki_deck_evernote_notebook_integration.setChecked( + mw.col.conf.get(SETTINGS.ANKI_DECK_EVERNOTE_NOTEBOOK_INTEGRATION, True)) + + +def update_default_anki_deck(text): + mw.col.conf[SETTINGS.DEFAULT_ANKI_DECK] = text + update_anki_deck_visibilities() + + +def update_anki_deck_evernote_notebook_integration(): + if default_anki_deck.text(): + mw.col.conf[ + SETTINGS.ANKI_DECK_EVERNOTE_NOTEBOOK_INTEGRATION] = anki_deck_evernote_notebook_integration.isChecked() + + +def update_evernote_tags_to_delete(text): + mw.col.conf[SETTINGS.EVERNOTE_TAGS_TO_DELETE] = text + + +def update_evernote_query_tags(text): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_TAGS] = text + if text: evernote_query_use_tags.setChecked(True) + evernote_query_text_changed() + + +def update_evernote_query_use_tags(): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_TAGS] = evernote_query_use_tags.isChecked() + update_evernote_query_visibilities() + + +def update_evernote_query_notebook(text): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_NOTEBOOK] = text + if text: evernote_query_use_notebook.setChecked(True) + evernote_query_text_changed() + + +def update_evernote_query_use_notebook(): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_NOTEBOOK] = evernote_query_use_notebook.isChecked() + update_evernote_query_visibilities() + + +def update_evernote_query_note_title(text): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_NOTE_TITLE] = text + if text: evernote_query_use_note_title.setChecked(True) + evernote_query_text_changed() + + +def update_evernote_query_use_note_title(): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_NOTE_TITLE] = evernote_query_use_note_title.isChecked() + update_evernote_query_visibilities() + + +def update_evernote_query_use_last_updated(): + update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_LAST_UPDATED] = evernote_query_use_last_updated.isChecked() + + +def update_evernote_query_search_terms(text): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_SEARCH_TERMS] = text + if text: evernote_query_use_search_terms.setChecked(True) + evernote_query_text_changed() + update_evernote_query_visibilities() + + +def update_evernote_query_use_search_terms(): + update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_SEARCH_TERMS] = evernote_query_use_search_terms.isChecked() + + +def update_evernote_query_any(): + update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE_QUERY_ANY] = evernote_query_any.isChecked() + + +def update_keep_evernote_tags(): + mw.col.conf[SETTINGS.KEEP_EVERNOTE_TAGS] = keep_evernote_tags.isChecked() + evernote_query_text_changed() + + +def update_delete_evernote_query_tags(): + mw.col.conf[SETTINGS.DELETE_EVERNOTE_TAGS_TO_IMPORT] = delete_evernote_query_tags.isChecked() + + +def update_evernote_pagination_auto_paging(): + mw.col.conf[SETTINGS.EVERNOTE_AUTO_PAGING] = evernote_pagination_auto_paging.isChecked() + + +def update_evernote_pagination_current_page_spinner(value): + if value < 1: + value = 1 + evernote_pagination_current_page_spinner.setValue(1) + mw.col.conf[SETTINGS.EVERNOTE_PAGINATION_CURRENT_PAGE] = value + + +def update_update_existing_notes(index): + mw.col.conf[SETTINGS.UPDATE_EXISTING_NOTES] = index + + +def evernote_query_text_changed(): + tags = evernote_query_tags.text() + search_terms = evernote_query_search_terms.text() + note_title = evernote_query_note_title.text() + notebook = evernote_query_notebook.text() + # tags_active = tags and evernote_query_use_tags.isChecked() + search_terms_active = search_terms and evernote_query_use_search_terms.isChecked() + note_title_active = note_title and evernote_query_use_note_title.isChecked() + notebook_active = notebook and evernote_query_use_notebook.isChecked() + all_inactive = not ( + search_terms_active or note_title_active or notebook_active or evernote_query_use_last_updated.isChecked()) + + if not search_terms: + evernote_query_use_search_terms.setEnabled(False) + evernote_query_use_search_terms.setChecked(False) + else: + evernote_query_use_search_terms.setEnabled(True) + evernote_query_use_search_terms.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_SEARCH_TERMS, True)) + + if not note_title: + evernote_query_use_note_title.setEnabled(False) + evernote_query_use_note_title.setChecked(False) + else: + evernote_query_use_note_title.setEnabled(True) + evernote_query_use_note_title.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTE_TITLE, True)) + + if not notebook: + evernote_query_use_notebook.setEnabled(False) + evernote_query_use_notebook.setChecked(False) + else: + evernote_query_use_notebook.setEnabled(True) + evernote_query_use_notebook.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTEBOOK, True)) + + if not tags and not all_inactive: + evernote_query_use_tags.setEnabled(False) + evernote_query_use_tags.setChecked(False) + else: + evernote_query_use_tags.setEnabled(True) + evernote_query_use_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_TAGS, True)) + if all_inactive and not tags: + evernote_query_tags.setText(SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE) + + +def update_evernote_query_visibilities(): + # is_any = evernote_query_any.isChecked() + is_tags = evernote_query_use_tags.isChecked() + is_terms = evernote_query_use_search_terms.isChecked() + is_title = evernote_query_use_note_title.isChecked() + is_notebook = evernote_query_use_notebook.isChecked() + is_updated = evernote_query_use_last_updated.isChecked() + + # is_disabled_any = not evernote_query_any.isEnabled() + is_disabled_tags = not evernote_query_use_tags.isEnabled() + is_disabled_terms = not evernote_query_use_search_terms.isEnabled() + is_disabled_title = not evernote_query_use_note_title.isEnabled() + is_disabled_notebook = not evernote_query_use_notebook.isEnabled() + # is_disabled_updated = not evernote_query_use_last_updated.isEnabled() + + override = (not is_tags and not is_terms and not is_title and not is_notebook and not is_updated) + if override: + is_tags = True + evernote_query_use_tags.setChecked(True) + evernote_query_tags.setEnabled(is_tags or is_disabled_tags) + evernote_query_search_terms.setEnabled(is_terms or is_disabled_terms) + evernote_query_note_title.setEnabled(is_title or is_disabled_title) + evernote_query_notebook.setEnabled(is_notebook or is_disabled_notebook) + evernote_query_last_updated_value_set_visibilities() + + +def update_evernote_query_last_updated_type(index): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_TYPE] = index + evernote_query_last_updated_value_set_visibilities() + + +def evernote_query_last_updated_value_get_current_value(): + index = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_TYPE, 0) + if index < EvernoteQueryLocationType.AbsoluteDate: + spinner_text = ['day', 'week', 'month', 'year'][index] + spinner_val = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_RELATIVE, 0) + if spinner_val > 0: spinner_text += "-" + str(spinner_val) + return spinner_text + + absolute_date_str = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_DATE, + "{:%Y %m %d}".format(datetime.now() - timedelta(days=7))).replace(' ', '') + if index == EvernoteQueryLocationType.AbsoluteDate: + return absolute_date_str + absolute_time_str = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_TIME, + "{:HH mm ss}".format(datetime.now())).replace(' ', '') + return absolute_date_str + "'T'" + absolute_time_str + + +def evernote_query_last_updated_value_set_visibilities(): + index = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_TYPE, 0) + if not evernote_query_use_last_updated.isChecked(): + evernote_query_last_updated_type.setEnabled(False) + evernote_query_last_updated_value_absolute_date.setEnabled(False) + evernote_query_last_updated_value_absolute_time.setEnabled(False) + evernote_query_last_updated_value_relative_spinner.setEnabled(False) + return + + evernote_query_last_updated_type.setEnabled(True) + evernote_query_last_updated_value_absolute_date.setEnabled(True) + evernote_query_last_updated_value_absolute_time.setEnabled(True) + evernote_query_last_updated_value_relative_spinner.setEnabled(True) + + absolute_date = QDate().fromString(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_DATE, + "{:%Y %m %d}".format(datetime.now() - timedelta(days=7))), + 'yyyy MM dd') + if index < EvernoteQueryLocationType.AbsoluteDate: + evernote_query_last_updated_value_absolute_date.setVisible(False) + evernote_query_last_updated_value_absolute_time.setVisible(False) + evernote_query_last_updated_value_relative_spinner.setVisible(True) + spinner_prefix = ['day', 'week', 'month', 'year'][index] + evernote_query_last_updated_value_relative_spinner.setPrefix(spinner_prefix) + evernote_query_last_updated_value_relative_spinner.setValue( + int(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_RELATIVE, 0))) + evernote_query_last_updated_value_stacked_layout.setCurrentIndex(0) + else: + evernote_query_last_updated_value_relative_spinner.setVisible(False) + evernote_query_last_updated_value_absolute_date.setVisible(True) + evernote_query_last_updated_value_absolute_date.setDate(absolute_date) + evernote_query_last_updated_value_stacked_layout.setCurrentIndex(1) + if index == EvernoteQueryLocationType.AbsoluteDate: + evernote_query_last_updated_value_absolute_time.setVisible(False) + evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + else: + absolute_time = QTime().fromString(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_TIME, + "{:HH mm ss}".format(datetime.now())), 'HH mm ss') + evernote_query_last_updated_value_absolute_time.setTime(absolute_time) + evernote_query_last_updated_value_absolute_time.setVisible(True) + evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + +def update_evernote_query_last_updated_value_relative_spinner(value): + if value < 0: + value = 0 + evernote_query_last_updated_value_relative_spinner.setValue(0) + mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_RELATIVE] = value + + +def update_evernote_query_last_updated_value_absolute_date(date): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_DATE] = date.toString('yyyy MM dd') + + +def update_evernote_query_last_updated_value_absolute_datetime(dt): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_DATE] = dt.toString('yyyy MM dd') + mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_TIME] = dt.toString('HH mm ss') + + +def update_evernote_query_last_updated_value_absolute_time(time_value): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_TIME] = time_value.toString('HH mm ss') + + +def generate_evernote_query(): + query = "" + tags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE).split(",") + if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTEBOOK, False): + query += 'notebook:"%s" ' % mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_NOTEBOOK, + SETTINGS.EVERNOTE_QUERY_NOTEBOOK_DEFAULT_VALUE).strip() + if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_ANY, True): + query += "any: " + if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTE_TITLE, False): + query_note_title = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_NOTE_TITLE, "") + if not query_note_title[:1] + query_note_title[-1:] == '""': + query_note_title = '"%s"' % query_note_title + query += 'intitle:%s ' % query_note_title + if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_TAGS, True): + for tag in tags: + query += "tag:%s " % tag.strip() + if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_LAST_UPDATED, False): + query += " updated:%s " % evernote_query_last_updated_value_get_current_value() + if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_SEARCH_TERMS, False): + query += mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_SEARCH_TERMS, "") + return query + + +def handle_show_generated_evernote_query(): + showInfo( + "The Evernote search query for your current options is below. You can press copy the text to your clipboard by pressing the copy keyboard shortcut (CTRL+C in Windows) while this message box has focus.\n\nQuery: %s" % generate_evernote_query(), + "Evernote Search Query") diff --git a/anknotes/shared.py b/anknotes/shared.py new file mode 100644 index 0000000..7ffc4d3 --- /dev/null +++ b/anknotes/shared.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +### Python Imports +try: + from pysqlite2 import dbapi2 as sqlite +except ImportError: + from sqlite3 import dbapi2 as sqlite + +### Check if in Anki +try: + from aqt import mw + inAnki = True +except: inAnki = False + +### Anknotes Imports +from anknotes.constants import * +from anknotes.logging import * +from anknotes.html import * +from anknotes.structs import * +from anknotes.db import * + +### Anki and Evernote Imports +try: + from aqt.qt import QIcon, QPixmap, QPushButton, QMessageBox + from aqt.utils import tooltip + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ + EDAMNotFoundException +except: + pass + +# log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') +def get_friendly_interval_string(lastImport): + if not lastImport: return "" + td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) + days = td.days + hours, remainder = divmod(td.total_seconds(), 3600) + minutes, seconds = divmod(remainder, 60) + if days > 1: + lastImportStr = "%d days" % td.days + else: + hours = round(hours) + hours_str = '' if hours == 0 else ('1:%2d hr' % minutes) if hours == 1 else '%d Hours' % hours + if days == 1: + lastImportStr = "One Day%s" % ('' if hours == 0 else ', ' + hours_str) + elif hours > 0: + lastImportStr = hours_str + else: + lastImportStr = "%d:%02d min" % (minutes, seconds) + return lastImportStr + + +class UpdateExistingNotes: + IgnoreExistingNotes, UpdateNotesInPlace, DeleteAndReAddNotes = range(3) + + +class EvernoteQueryLocationType: + RelativeDay, RelativeWeek, RelativeMonth, RelativeYear, AbsoluteDate, AbsoluteDateTime = range(6) + + +def get_tag_names_to_import(tagNames, evernoteTags=None, evernoteTagsToDelete=None, keepEvernoteQueryTags=True): + if keepEvernoteQueryTags is None: keepEvernoteQueryTags = mw.col.conf.get(SETTINGS.DELETE_EVERNOTE_TAGS_TO_IMPORT, True) + if not keepEvernoteQueryTags: return {} if isinstance(tagNames, dict) else [] + if evernoteTags is None: evernoteTags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE).split(",") + if evernoteTagsToDelete is None: evernoteTagsToDelete = mw.col.conf.get(SETTINGS.EVERNOTE_TAGS_TO_DELETE, "").split(",") + tags_to_delete = evernoteTags + evernoteTagsToDelete + if isinstance(tagNames, dict): + return {k: v for k, v in tagNames.items() if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)} + return sorted([v for v in tagNames if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)], + key=lambda s: s.lower()) + +def find_evernote_guids(content): + return [x.group('guid') for x in re.finditer(r'\b(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b', content)] + +def find_evernote_links_as_guids(content): + return [x.Guid for x in find_evernote_links(content)] + +def replace_evernote_web_links(content): + return re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', + r'evernote:///view/\2/\1/\3/\3/', content) + +def find_evernote_links(content): + """ + + :param content: + :return: + :rtype : list[EvernoteLink] + """ + # .NET regex saved to regex.txt as 'Finding Evernote Links' + content = replace_evernote_web_links(content) + regex_str = r'<a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)"(?:[^>]+)?>(?P<title>.+?)</a>' + ids = get_evernote_account_ids() + if not ids.valid: + match = re.search(regex_str, content) + if match: + ids.update(match.group('uid'), match.group('shard')) + return [EvernoteLink(m) for m in re.finditer(regex_str, content)] + +def get_dict_from_list(lst, keys_to_ignore=list()): + dic = {} + for key, value in lst: + if not key in keys_to_ignore: dic[key] = value + return dic + +_regex_see_also = None + +def update_regex(): + global _regex_see_also + regex_str = file(os.path.join(ANKNOTES.FOLDER_ANCILLARY, 'regex-see_also.txt'), 'r').read() + regex_str = regex_str.replace('(?<', '(?P<') + _regex_see_also = re.compile(regex_str, re.UNICODE | re.VERBOSE | re.DOTALL) + + +def regex_see_also(): + global _regex_see_also + if not _regex_see_also: update_regex() + return _regex_see_also diff --git a/anknotes/structs.py b/anknotes/structs.py new file mode 100644 index 0000000..0cda3f9 --- /dev/null +++ b/anknotes/structs.py @@ -0,0 +1,686 @@ +import re +import anknotes +from anknotes.db import * +from anknotes.enum import Enum +from anknotes.logging import log, str_safe, pad_center +from anknotes.html import strip_tags +from anknotes.enums import * +from anknotes.EvernoteNoteTitle import EvernoteNoteTitle + +# from evernote.edam.notestore.ttypes import NoteMetadata, NotesMetadataList + +def upperFirst(name): + return name[0].upper() + name[1:] + +def getattrcallable(obj, attr): + val = getattr(obj, attr) + if callable(val): return val() + return val + +# from anknotes.EvernoteNotePrototype import EvernoteNotePrototype +# from anknotes.EvernoteNoteTitle import EvernoteNoteTitle + +class EvernoteStruct(object): + success = False + Name = "" + Guid = "" + __sql_columns__ = "name" + __sql_table__ = TABLES.EVERNOTE.TAGS + __sql_where__ = "guid" + __attr_order__ = [] + __title_is_note_title = False + + def __attr_from_key__(self, key): + return upperFirst(key) + + def keys(self): + return self._valid_attributes_() + + def items(self): + return [self.getAttribute(key) for key in self.__attr_order__] + + def sqlUpdateQuery(self): + columns = self.__attr_order__ if self.__attr_order__ else self.__sql_columns__ + return "INSERT OR REPLACE INTO `%s`(%s) VALUES (%s)" % (self.__sql_table__, '`' + '`,`'.join(columns) + '`', ', '.join(['?']*len(columns))) + + def sqlSelectQuery(self, allColumns=True): + return "SELECT %s FROM %s WHERE %s = '%s'" % ( + '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) + + def getFromDB(self, allColumns=True): + query = "SELECT %s FROM %s WHERE %s = '%s'" % ( + '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) + ankDB().setrowfactory() + result = ankDB().first(self.sqlSelectQuery(allColumns)) + if result: + self.success = True + self.setFromKeyedObject(result) + else: + self.success = False + return self.success + + @property + def Where(self): + return self.getAttribute(self.__sql_where__) + + @Where.setter + def Where(self, value): + self.setAttribute(self.__sql_where__, value) + + def getAttribute(self, key, default=None, raiseIfInvalidKey=False): + if not self.hasAttribute(key): + if raiseIfInvalidKey: raise KeyError + return default + return getattr(self, self.__attr_from_key__(key)) + + def hasAttribute(self, key): + return hasattr(self, self.__attr_from_key__(key)) + + def setAttribute(self, key, value): + if key == "fetch_" + self.__sql_where__: + self.setAttribute(self.__sql_where__, value) + self.getFromDB() + elif self._is_valid_attribute_(key): + setattr(self, self.__attr_from_key__(key), value) + + def setAttributeByObject(self, key, keyed_object): + self.setAttribute(key, keyed_object[key]) + + def setFromKeyedObject(self, keyed_object, keys=None): + """ + + :param keyed_object: + :type: sqlite.Row | dict[str, object] | re.MatchObject | _sre.SRE_Match + :return: + """ + lst = self._valid_attributes_() + if keys or isinstance(keyed_object, dict): + pass + elif isinstance(keyed_object, type(re.search('', ''))): + keyed_object = keyed_object.groupdict() + elif hasattr(keyed_object, 'keys'): + keys = getattrcallable(keyed_object, 'keys') + elif hasattr(keyed_object, self.__sql_where__): + for key in self.keys(): + if hasattr(keyed_object, key): self.setAttribute(key, getattr(keyed_object, key)) + return True + else: + return False + + if keys is None: keys = keyed_object + for key in keys: + if key == "fetch_" + self.__sql_where__: + self.Where = keyed_object[key] + self.getFromDB() + elif key in lst: self.setAttributeByObject(key, keyed_object) + return True + + def setFromListByDefaultOrder(self, args): + i = 0 + max = len(self.__attr_order__) + for value in args: + if i > max: + log("Unable to set attr #%d for %s to %s (Exceeds # of default attributes)" % (i, self.__class__.__name__, str_safe(value)), 'error') + return + self.setAttribute(self.__attr_order__[i], value) + i += 1 + # I have no idea what I was trying to do when I coded the commented out conditional statement... + # if key in self.__attr_order__: + + # else: + # log("Unable to set attr #%d for %s to %s" % (i, self.__class__.__name__, str_safe(value)), 'error') + + + def _valid_attributes_(self): + return set().union(self.__sql_columns__, [self.__sql_where__], self.__attr_order__) + + def _is_valid_attribute_(self, attribute): + return attribute.lower() in self._valid_attributes_() + + def __init__(self, *args, **kwargs): + if isinstance(self.__sql_columns__, str): self.__sql_columns__ = [self.__sql_columns__] + if isinstance(self.__attr_order__, str) or isinstance(self.__attr_order__, unicode): + self.__attr_order__ = self.__attr_order__.replace('|', ' ').split(' ') + args = list(args) + if args and self.setFromKeyedObject(args[0]): del args[0] + self.setFromListByDefaultOrder(args) + self.setFromKeyedObject(kwargs) + +class EvernoteNotebook(EvernoteStruct): + Stack = "" + __sql_columns__ = ["name", "stack"] + __sql_table__ = TABLES.EVERNOTE.NOTEBOOKS + + +class EvernoteTag(EvernoteStruct): + ParentGuid = "" + UpdateSequenceNum = -1 + __sql_columns__ = ["name", "parentGuid"] + __sql_table__ = TABLES.EVERNOTE.TAGS + __attr_order__ = 'guid|name|parentGuid|updateSequenceNum' + + +class EvernoteLink(EvernoteStruct): + __uid__ = -1 + Shard = -1 + Guid = "" + __title__ = None + """:type: EvernoteNoteTitle.EvernoteNoteTitle """ + __attr_order__ = 'uid|shard|guid|title' + + @property + def HTML(self): + return self.Title.HTML + + @property + def Title(self): + """:rtype : EvernoteNoteTitle.EvernoteNoteTitle""" + return self.__title__ + + @property + def FullTitle(self): + return self.Title.FullTitle + + @Title.setter + def Title(self, value): + """ + :param value: + :type value : EvernoteNoteTitle.EvernoteNoteTitle | str | unicode + :return: + """ + self.__title__ = anknotes.EvernoteNoteTitle.EvernoteNoteTitle(value) + """:type : EvernoteNoteTitle.EvernoteNoteTitle""" + + @property + def Uid(self): + return int(self.__uid__) + + @Uid.setter + def Uid(self, value): + self.__uid__ = int(value) + +class EvernoteTOCEntry(EvernoteStruct): + RealTitle = "" + """:type : str""" + OrderedList = "" + """ + HTML output of Root Title's Ordererd List + :type : str + """ + TagNames = "" + """:type : str""" + NotebookGuid = "" + + def __init__(self, *args, **kwargs): + self.__attr_order__ = 'realTitle|orderedList|tagNames|notebookGuid' + super(self.__class__, self).__init__(*args, **kwargs) + + +class EvernoteValidationEntry(EvernoteStruct): + Guid = "" + """:type : str""" + Title = "" + """:type : str""" + Contents = "" + """:type : str""" + TagNames = "" + """:type : str""" + NotebookGuid = "" + + def __init__(self, *args, **kwargs): + # spr = super(self.__class__ , self) + # spr.__attr_order__ = self.__attr_order__ + # spr.__init__(*args, **kwargs) + self.__attr_order__ = 'guid|title|contents|tagNames|notebookGuid' + super(self.__class__, self).__init__(*args, **kwargs) + + +class EvernoteAPIStatusOld(AutoNumber): + Uninitialized = -100 + """:type : EvernoteAPIStatus""" + EmptyRequest = -3 + """:type : EvernoteAPIStatus""" + Manual = -2 + """:type : EvernoteAPIStatus""" + RequestQueued = -1 + """:type : EvernoteAPIStatus""" + Success = 0 + """:type : EvernoteAPIStatus""" + RateLimitError = () + """:type : EvernoteAPIStatus""" + SocketError = () + """:type : EvernoteAPIStatus""" + UserError = () + """:type : EvernoteAPIStatus""" + NotFoundError = () + """:type : EvernoteAPIStatus""" + UnhandledError = () + """:type : EvernoteAPIStatus""" + Unknown = 100 + """:type : EvernoteAPIStatus""" + + def __getitem__(self, item): + """:rtype : EvernoteAPIStatus""" + + return super(self.__class__, self).__getitem__(item) + + # def __new__(cls, *args, **kwargs): + # """:rtype : EvernoteAPIStatus""" + # return type(cls).__new__(*args, **kwargs) + + @property + def IsError(self): + return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value + + @property + def IsSuccessful(self): + return EvernoteAPIStatus.Success.value >= self.value > EvernoteAPIStatus.Uninitialized.value + + @property + def IsSuccess(self): + return self == EvernoteAPIStatus.Success + + + +class EvernoteAPIStatus(AutoNumberedEnum): + Uninitialized = -100 + """:type : EvernoteAPIStatus""" + EmptyRequest = -3 + """:type : EvernoteAPIStatus""" + Manual = -2 + """:type : EvernoteAPIStatus""" + RequestQueued = -1 + """:type : EvernoteAPIStatus""" + Success = 0 + """:type : EvernoteAPIStatus""" + RateLimitError = () + """:type : EvernoteAPIStatus""" + SocketError = () + """:type : EvernoteAPIStatus""" + UserError = () + """:type : EvernoteAPIStatus""" + NotFoundError = () + """:type : EvernoteAPIStatus""" + UnhandledError = () + """:type : EvernoteAPIStatus""" + Unknown = 100 + """:type : EvernoteAPIStatus""" + + # def __new__(cls, *args, **kwargs): + # """:rtype : EvernoteAPIStatus""" + # return type(cls).__new__(*args, **kwargs) + + @property + def IsError(self): + return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value + + @property + def IsSuccessful(self): + return EvernoteAPIStatus.Success.value >= self.value > EvernoteAPIStatus.Uninitialized.value + + @property + def IsSuccess(self): + return self == EvernoteAPIStatus.Success + + +class EvernoteImportType: + Add, UpdateInPlace, DeleteAndUpdate = range(3) + + +class EvernoteNoteFetcherResult(object): + def __init__(self, note=None, status=None, source=-1): + """ + + :type note: EvernoteNotePrototype.EvernoteNotePrototype + :type status: EvernoteAPIStatus + """ + if not status: status = EvernoteAPIStatus.Uninitialized + self.Note = note + self.Status = status + self.Source = source + + +class EvernoteNoteFetcherResults(object): + Status = EvernoteAPIStatus.Uninitialized + ImportType = EvernoteImportType.Add + Local = 0 + Notes = [] + Imported = 0 + Max = 0 + AlreadyUpToDate = 0 + + @property + def DownloadSuccess(self): + return self.Count == self.Max + + @property + def AnkiSuccess(self): + return self.Imported == self.Count + + @property + def TotalSuccess(self): + return self.DownloadSuccess and self.AnkiSuccess + + @property + def LocalDownloadsOccurred(self): + return self.Local > 0 + + @property + def Remote(self): + return self.Count - self.Local + + @property + def SummaryShort(self): + add_update_strs = ['New', "Added"] if self.ImportType == EvernoteImportType.Add else ['Existing', 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated'] + return "%d %s Notes Have Been %s" % (self.Imported, add_update_strs[0], add_update_strs[1]) + + @property + def SummaryLines(self): + if self.Max is 0: return [] + add_update_strs = ['New', "Added to"] if self.ImportType == EvernoteImportType.Add else ['Existing', "%s in" % ('Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated')] + add_update_strs[1] += " Anki" + + ## Evernote Status + if self.DownloadSuccess: + line = "All %3d" % self.Max + else: + line = "%3d of %3d" % (self.Count, self.Max) + lines = [line + " %s Evernote Metadata Results Were Successfully Downloaded%s." % ( + add_update_strs[0], (' And %s' % add_update_strs[1]) if self.AnkiSuccess else '')] + if self.Status.IsError: + lines.append("-An error occurred during download (%s)." % str(self.Status)) + + ## Local Calls + if self.LocalDownloadsOccurred: + lines.append( + "-%d %s note(s) were unexpectedly found in the local db and did not require an API call." % (self.Local, add_update_strs[0])) + lines.append("-%d %s note(s) required an API call" % (self.Remote, add_update_strs[0])) + if not self.ImportType == EvernoteImportType.Add and self.AlreadyUpToDate > 0: + lines.append( + "-%3d existing note(s) are already up-to-date with Evernote's servers, so they were not retrieved." % self.AlreadyUpToDate) + + ## Anki Status + if self.DownloadSuccess: + return lines + if self.AnkiSuccess: + line = "All %3d" % self.Imported + else: + line = "%3d of %3d" % (self.Imported, self.Count) + lines.append(line + " %s Downloaded Evernote Notes Have Been Successfully %s." % ( + add_update_strs[0], add_update_strs[1])) + + + + return lines + + @property + def Summary(self): + lines = self.SummaryLines + if len(lines) is 0: + return '' + return '<BR> - '.join(lines) + + @property + def Count(self): + return len(self.Notes) + + @property + def EvernoteFails(self): + return self.Max - self.Count + + @property + def AnkiFails(self): + return self.Count - self.Imported + + def __init__(self, status=None, local=None): + """ + :param status: + :type status : EvernoteAPIStatus + :param local: + :return: + """ + if not status: status = EvernoteAPIStatus.Uninitialized + if not local: local = 0 + self.Status = status + self.Local = local + self.Imported = 0 + self.Notes = [] + """ + :type : list[EvernoteNotePrototype.EvernoteNotePrototype] + """ + + def reportResult(self, result): + """ + :type result : EvernoteNoteFetcherResult + """ + self.Status = result.Status + if self.Status == EvernoteAPIStatus.Success: + self.Notes.append(result.Note) + if result.Source == 1: + self.Local += 1 + + +class EvernoteImportProgress: + Anki = None + """:type : anknotes.Anki.Anki""" + + class _GUIDs: + Local = None + + class Server: + All = None + New = None + + class Existing: + All = None + UpToDate = None + OutOfDate = None + + def loadNew(self, server_evernote_guids=None): + if server_evernote_guids: + self.Server.All = server_evernote_guids + if not self.Server.All: + return + setServer = set(self.Server.All) + self.Server.New = setServer - set(self.Local) + self.Server.Existing.All = setServer - set(self.Server.New) + + class Results: + Adding = None + """:type : EvernoteNoteFetcherResults""" + Updating = None + """:type : EvernoteNoteFetcherResults""" + + GUIDs = _GUIDs() + + @property + def Adding(self): + return len(self.GUIDs.Server.New) + + @property + def Updating(self): + return len(self.GUIDs.Server.Existing.OutOfDate) + + @property + def AlreadyUpToDate(self): + return len(self.GUIDs.Server.Existing.UpToDate) + + @property + def Success(self): + return self.Status == EvernoteAPIStatus.Success + + @property + def IsError(self): + return self.Status.IsError + + @property + def Status(self): + s1 = self.Results.Adding.Status + s2 = self.Results.Updating.Status if self.Results.Updating else EvernoteAPIStatus.Uninitialized + if s1 == EvernoteAPIStatus.RateLimitError or s2 == EvernoteAPIStatus.RateLimitError: + return EvernoteAPIStatus.RateLimitError + if s1 == EvernoteAPIStatus.SocketError or s2 == EvernoteAPIStatus.SocketError: + return EvernoteAPIStatus.SocketError + if s1.IsError: + return s1 + if s2.IsError: + return s2 + if s1.IsSuccessful and s2.IsSuccessful: + return EvernoteAPIStatus.Success + if s2 == EvernoteAPIStatus.Uninitialized: + return s1 + if s1 == EvernoteAPIStatus.Success: + return s2 + return s1 + + @property + def Summary(self): + lst = [ + "New Notes (%d)" % self.Adding, + "Existing Out-Of-Date Notes (%d)" % self.Updating, + "Existing Up-To-Date Notes (%d)" % self.AlreadyUpToDate + ] + + return ' > '.join(lst) + + def loadAlreadyUpdated(self, db_guids): + self.GUIDs.Server.Existing.UpToDate = db_guids + self.GUIDs.Server.Existing.OutOfDate = set(self.GUIDs.Server.Existing.All) - set( + self.GUIDs.Server.Existing.UpToDate) + + def processUpdateInPlaceResults(self, results): + return self.processResults(results, EvernoteImportType.UpdateInPlace) + + def processDeleteAndUpdateResults(self, results): + return self.processResults(results, EvernoteImportType.DeleteAndUpdate) + + @property + def ResultsSummaryShort(self): + line = self.Results.Adding.SummaryShort + if self.Results.Adding.Status.IsError: + line += " to Anki. Skipping update due to an error (%s)" % self.Results.Adding.Status + elif not self.Results.Updating: + line += " to Anki. Updating is disabled" + else: + line += " and " + self.Results.Updating.SummaryShort + return line + + @property + def ResultsSummaryLines(self): + lines = [self.ResultsSummaryShort] + self.Results.Adding.SummaryLines + if self.Results.Updating: + lines += self.Results.Updating.SummaryLines + return lines + + @property + def APICallCount(self): + return self.Results.Adding.Remote + self.Results.Updating.Remote if self.Results.Updating else 0 + + def processResults(self, results, importType=None): + """ + :type results : EvernoteNoteFetcherResults + :type importType : EvernoteImportType + """ + if not importType: + importType = EvernoteImportType.Add + results.ImportType = importType + if importType == EvernoteImportType.Add: + results.Max = self.Adding + results.AlreadyUpToDate = 0 + self.Results.Adding = results + else: + results.Max = self.Updating + results.AlreadyUpToDate = self.AlreadyUpToDate + self.Results.Updating = results + + def setup(self, anki_note_ids=None): + if not anki_note_ids: + anki_note_ids = self.Anki.get_anknotes_note_ids() + self.GUIDs.Local = self.Anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) + + def __init__(self, anki=None, metadataProgress=None, server_evernote_guids=None, anki_note_ids=None): + """ + :param anki: Anknotes Main Anki Instance + :type anki: anknotes.Anki.Anki + :type metadataProgress: EvernoteMetadataProgress + :return: + """ + if not anki: + return + self.Anki = anki + self.setup(anki_note_ids) + if metadataProgress: + server_evernote_guids = metadataProgress.Guids + if server_evernote_guids: + self.GUIDs.loadNew(server_evernote_guids) + self.Results.Adding = EvernoteNoteFetcherResults() + self.Results.Updating = EvernoteNoteFetcherResults() + + +class EvernoteMetadataProgress: + Page = 1 + Total = -1 + Current = -1 + UpdateCount = 0 + Status = EvernoteAPIStatus.Uninitialized + Guids = [] + NotesMetadata = {} + """ + :type: dict[str, anknotes.evernote.edam.notestore.ttypes.NoteMetadata] + """ + + @property + def IsFinished(self): + return self.Remaining <= 0 + + @property + def List(self): + return ["Total Notes: %d" % self.Total, + "Returned Notes: %d" % self.Current, + "Result Range: %d-%d" % (self.Offset, self.Completed), + "Remaining Notes: %d" % self.Remaining, + "Update Count: %d" % self.UpdateCount] + + @property + def ListPadded(self): + lst = [] + for val in self.List: + + lst.append(pad_center(val, 25)) + return lst + + @property + def Summary(self): + return ' | '.join(self.ListPadded) + + @property + def Offset(self): + return (self.Page - 1) * 250 + + @property + def Completed(self): + return self.Current + self.Offset + + @property + def Remaining(self): + return self.Total - self.Completed + + def __init__(self, page=1): + self.Page = int(page) + + def loadResults(self, result): + """ + :param result: Result Returned by Evernote API Call to getNoteMetadata + :type result: anknotes.evernote.edam.notestore.ttypes.NotesMetadataList + :return: + """ + self.Total = int(result.totalNotes) + self.Current = len(result.notes) + self.UpdateCount = result.updateCount + self.Status = EvernoteAPIStatus.Success + self.Guids = [] + self.NotesMetadata = {} + for note in result.notes: + # assert isinstance(note, NoteMetadata) + self.Guids.append(note.guid) + self.NotesMetadata[note.guid] = note diff --git a/anknotes/thrift/TSCons.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/TSCons.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 2404625..0000000 --- a/anknotes/thrift/TSCons.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,33 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from os import path -from SCons.Builder import Builder - -def scons_env(env, add=''): - opath = path.dirname(path.abspath('$TARGET')) - lstr = 'thrift --gen cpp -o ' + opath + ' ' + add + ' $SOURCE' - cppbuild = Builder(action = lstr) - env.Append(BUILDERS = {'ThriftCpp' : cppbuild}) - -def gen_cpp(env, dir, file): - scons_env(env) - suffixes = ['_types.h', '_types.cpp'] - targets = map(lambda s: 'gen-cpp/' + file + s, suffixes) - return env.ThriftCpp(targets, dir+file+'.thrift') diff --git a/anknotes/thrift/TSCons.py~HEAD b/anknotes/thrift/TSCons.py~HEAD deleted file mode 100644 index 2404625..0000000 --- a/anknotes/thrift/TSCons.py~HEAD +++ /dev/null @@ -1,33 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from os import path -from SCons.Builder import Builder - -def scons_env(env, add=''): - opath = path.dirname(path.abspath('$TARGET')) - lstr = 'thrift --gen cpp -o ' + opath + ' ' + add + ' $SOURCE' - cppbuild = Builder(action = lstr) - env.Append(BUILDERS = {'ThriftCpp' : cppbuild}) - -def gen_cpp(env, dir, file): - scons_env(env) - suffixes = ['_types.h', '_types.cpp'] - targets = map(lambda s: 'gen-cpp/' + file + s, suffixes) - return env.ThriftCpp(targets, dir+file+'.thrift') diff --git a/anknotes/thrift/TSerialization.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/TSerialization.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index b19f98a..0000000 --- a/anknotes/thrift/TSerialization.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,34 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from protocol import TBinaryProtocol -from transport import TTransport - -def serialize(thrift_object, protocol_factory = TBinaryProtocol.TBinaryProtocolFactory()): - transport = TTransport.TMemoryBuffer() - protocol = protocol_factory.getProtocol(transport) - thrift_object.write(protocol) - return transport.getvalue() - -def deserialize(base, buf, protocol_factory = TBinaryProtocol.TBinaryProtocolFactory()): - transport = TTransport.TMemoryBuffer(buf) - protocol = protocol_factory.getProtocol(transport) - base.read(protocol) - return base - diff --git a/anknotes/thrift/TSerialization.py~HEAD b/anknotes/thrift/TSerialization.py~HEAD deleted file mode 100644 index b19f98a..0000000 --- a/anknotes/thrift/TSerialization.py~HEAD +++ /dev/null @@ -1,34 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from protocol import TBinaryProtocol -from transport import TTransport - -def serialize(thrift_object, protocol_factory = TBinaryProtocol.TBinaryProtocolFactory()): - transport = TTransport.TMemoryBuffer() - protocol = protocol_factory.getProtocol(transport) - thrift_object.write(protocol) - return transport.getvalue() - -def deserialize(base, buf, protocol_factory = TBinaryProtocol.TBinaryProtocolFactory()): - transport = TTransport.TMemoryBuffer(buf) - protocol = protocol_factory.getProtocol(transport) - base.read(protocol) - return base - diff --git a/anknotes/thrift/Thrift.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/Thrift.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 1d271fc..0000000 --- a/anknotes/thrift/Thrift.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,154 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -import sys - -class TType: - STOP = 0 - VOID = 1 - BOOL = 2 - BYTE = 3 - I08 = 3 - DOUBLE = 4 - I16 = 6 - I32 = 8 - I64 = 10 - STRING = 11 - UTF7 = 11 - STRUCT = 12 - MAP = 13 - SET = 14 - LIST = 15 - UTF8 = 16 - UTF16 = 17 - - _VALUES_TO_NAMES = ( 'STOP', - 'VOID', - 'BOOL', - 'BYTE', - 'DOUBLE', - None, - 'I16', - None, - 'I32', - None, - 'I64', - 'STRING', - 'STRUCT', - 'MAP', - 'SET', - 'LIST', - 'UTF8', - 'UTF16' ) - -class TMessageType: - CALL = 1 - REPLY = 2 - EXCEPTION = 3 - ONEWAY = 4 - -class TProcessor: - - """Base class for procsessor, which works on two streams.""" - - def process(iprot, oprot): - pass - -class TException(Exception): - - """Base class for all thrift exceptions.""" - - # BaseException.message is deprecated in Python v[2.6,3.0) - if (2,6,0) <= sys.version_info < (3,0): - def _get_message(self): - return self._message - def _set_message(self, message): - self._message = message - message = property(_get_message, _set_message) - - def __init__(self, message=None): - Exception.__init__(self, message) - self.message = message - -class TApplicationException(TException): - - """Application level thrift exceptions.""" - - UNKNOWN = 0 - UNKNOWN_METHOD = 1 - INVALID_MESSAGE_TYPE = 2 - WRONG_METHOD_NAME = 3 - BAD_SEQUENCE_ID = 4 - MISSING_RESULT = 5 - INTERNAL_ERROR = 6 - PROTOCOL_ERROR = 7 - - def __init__(self, type=UNKNOWN, message=None): - TException.__init__(self, message) - self.type = type - - def __str__(self): - if self.message: - return self.message - elif self.type == self.UNKNOWN_METHOD: - return 'Unknown method' - elif self.type == self.INVALID_MESSAGE_TYPE: - return 'Invalid message type' - elif self.type == self.WRONG_METHOD_NAME: - return 'Wrong method name' - elif self.type == self.BAD_SEQUENCE_ID: - return 'Bad sequence ID' - elif self.type == self.MISSING_RESULT: - return 'Missing result' - else: - return 'Default (unknown) TApplicationException' - - def read(self, iprot): - iprot.readStructBegin() - while True: - (fname, ftype, fid) = iprot.readFieldBegin() - if ftype == TType.STOP: - break - if fid == 1: - if ftype == TType.STRING: - self.message = iprot.readString(); - else: - iprot.skip(ftype) - elif fid == 2: - if ftype == TType.I32: - self.type = iprot.readI32(); - else: - iprot.skip(ftype) - else: - iprot.skip(ftype) - iprot.readFieldEnd() - iprot.readStructEnd() - - def write(self, oprot): - oprot.writeStructBegin('TApplicationException') - if self.message != None: - oprot.writeFieldBegin('message', TType.STRING, 1) - oprot.writeString(self.message) - oprot.writeFieldEnd() - if self.type != None: - oprot.writeFieldBegin('type', TType.I32, 2) - oprot.writeI32(self.type) - oprot.writeFieldEnd() - oprot.writeFieldStop() - oprot.writeStructEnd() diff --git a/anknotes/thrift/Thrift.py~HEAD b/anknotes/thrift/Thrift.py~HEAD deleted file mode 100644 index 1d271fc..0000000 --- a/anknotes/thrift/Thrift.py~HEAD +++ /dev/null @@ -1,154 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -import sys - -class TType: - STOP = 0 - VOID = 1 - BOOL = 2 - BYTE = 3 - I08 = 3 - DOUBLE = 4 - I16 = 6 - I32 = 8 - I64 = 10 - STRING = 11 - UTF7 = 11 - STRUCT = 12 - MAP = 13 - SET = 14 - LIST = 15 - UTF8 = 16 - UTF16 = 17 - - _VALUES_TO_NAMES = ( 'STOP', - 'VOID', - 'BOOL', - 'BYTE', - 'DOUBLE', - None, - 'I16', - None, - 'I32', - None, - 'I64', - 'STRING', - 'STRUCT', - 'MAP', - 'SET', - 'LIST', - 'UTF8', - 'UTF16' ) - -class TMessageType: - CALL = 1 - REPLY = 2 - EXCEPTION = 3 - ONEWAY = 4 - -class TProcessor: - - """Base class for procsessor, which works on two streams.""" - - def process(iprot, oprot): - pass - -class TException(Exception): - - """Base class for all thrift exceptions.""" - - # BaseException.message is deprecated in Python v[2.6,3.0) - if (2,6,0) <= sys.version_info < (3,0): - def _get_message(self): - return self._message - def _set_message(self, message): - self._message = message - message = property(_get_message, _set_message) - - def __init__(self, message=None): - Exception.__init__(self, message) - self.message = message - -class TApplicationException(TException): - - """Application level thrift exceptions.""" - - UNKNOWN = 0 - UNKNOWN_METHOD = 1 - INVALID_MESSAGE_TYPE = 2 - WRONG_METHOD_NAME = 3 - BAD_SEQUENCE_ID = 4 - MISSING_RESULT = 5 - INTERNAL_ERROR = 6 - PROTOCOL_ERROR = 7 - - def __init__(self, type=UNKNOWN, message=None): - TException.__init__(self, message) - self.type = type - - def __str__(self): - if self.message: - return self.message - elif self.type == self.UNKNOWN_METHOD: - return 'Unknown method' - elif self.type == self.INVALID_MESSAGE_TYPE: - return 'Invalid message type' - elif self.type == self.WRONG_METHOD_NAME: - return 'Wrong method name' - elif self.type == self.BAD_SEQUENCE_ID: - return 'Bad sequence ID' - elif self.type == self.MISSING_RESULT: - return 'Missing result' - else: - return 'Default (unknown) TApplicationException' - - def read(self, iprot): - iprot.readStructBegin() - while True: - (fname, ftype, fid) = iprot.readFieldBegin() - if ftype == TType.STOP: - break - if fid == 1: - if ftype == TType.STRING: - self.message = iprot.readString(); - else: - iprot.skip(ftype) - elif fid == 2: - if ftype == TType.I32: - self.type = iprot.readI32(); - else: - iprot.skip(ftype) - else: - iprot.skip(ftype) - iprot.readFieldEnd() - iprot.readStructEnd() - - def write(self, oprot): - oprot.writeStructBegin('TApplicationException') - if self.message != None: - oprot.writeFieldBegin('message', TType.STRING, 1) - oprot.writeString(self.message) - oprot.writeFieldEnd() - if self.type != None: - oprot.writeFieldBegin('type', TType.I32, 2) - oprot.writeI32(self.type) - oprot.writeFieldEnd() - oprot.writeFieldStop() - oprot.writeStructEnd() diff --git a/anknotes/thrift/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 48d659c..0000000 --- a/anknotes/thrift/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,20 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -__all__ = ['Thrift', 'TSCons'] diff --git a/anknotes/thrift/__init__.py~HEAD b/anknotes/thrift/__init__.py~HEAD deleted file mode 100644 index 48d659c..0000000 --- a/anknotes/thrift/__init__.py~HEAD +++ /dev/null @@ -1,20 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -__all__ = ['Thrift', 'TSCons'] diff --git a/anknotes/thrift/protocol/TBase.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/protocol/TBase.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index e675c7d..0000000 --- a/anknotes/thrift/protocol/TBase.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,72 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from thrift.Thrift import * -from thrift.protocol import TBinaryProtocol -from thrift.transport import TTransport - -try: - from thrift.protocol import fastbinary -except: - fastbinary = None - -class TBase(object): - __slots__ = [] - - def __repr__(self): - L = ['%s=%r' % (key, getattr(self, key)) - for key in self.__slots__ ] - return '%s(%s)' % (self.__class__.__name__, ', '.join(L)) - - def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - for attr in self.__slots__: - my_val = getattr(self, attr) - other_val = getattr(other, attr) - if my_val != other_val: - return False - return True - - def __ne__(self, other): - return not (self == other) - - def read(self, iprot): - if iprot.__class__ == TBinaryProtocol.TBinaryProtocolAccelerated and isinstance(iprot.trans, TTransport.CReadableTransport) and self.thrift_spec is not None and fastbinary is not None: - fastbinary.decode_binary(self, iprot.trans, (self.__class__, self.thrift_spec)) - return - iprot.readStruct(self, self.thrift_spec) - - def write(self, oprot): - if oprot.__class__ == TBinaryProtocol.TBinaryProtocolAccelerated and self.thrift_spec is not None and fastbinary is not None: - oprot.trans.write(fastbinary.encode_binary(self, (self.__class__, self.thrift_spec))) - return - oprot.writeStruct(self, self.thrift_spec) - -class TExceptionBase(Exception): - # old style class so python2.4 can raise exceptions derived from this - # This can't inherit from TBase because of that limitation. - __slots__ = [] - - __repr__ = TBase.__repr__.im_func - __eq__ = TBase.__eq__.im_func - __ne__ = TBase.__ne__.im_func - read = TBase.read.im_func - write = TBase.write.im_func - diff --git a/anknotes/thrift/protocol/TBase.py~HEAD b/anknotes/thrift/protocol/TBase.py~HEAD deleted file mode 100644 index e675c7d..0000000 --- a/anknotes/thrift/protocol/TBase.py~HEAD +++ /dev/null @@ -1,72 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from thrift.Thrift import * -from thrift.protocol import TBinaryProtocol -from thrift.transport import TTransport - -try: - from thrift.protocol import fastbinary -except: - fastbinary = None - -class TBase(object): - __slots__ = [] - - def __repr__(self): - L = ['%s=%r' % (key, getattr(self, key)) - for key in self.__slots__ ] - return '%s(%s)' % (self.__class__.__name__, ', '.join(L)) - - def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - for attr in self.__slots__: - my_val = getattr(self, attr) - other_val = getattr(other, attr) - if my_val != other_val: - return False - return True - - def __ne__(self, other): - return not (self == other) - - def read(self, iprot): - if iprot.__class__ == TBinaryProtocol.TBinaryProtocolAccelerated and isinstance(iprot.trans, TTransport.CReadableTransport) and self.thrift_spec is not None and fastbinary is not None: - fastbinary.decode_binary(self, iprot.trans, (self.__class__, self.thrift_spec)) - return - iprot.readStruct(self, self.thrift_spec) - - def write(self, oprot): - if oprot.__class__ == TBinaryProtocol.TBinaryProtocolAccelerated and self.thrift_spec is not None and fastbinary is not None: - oprot.trans.write(fastbinary.encode_binary(self, (self.__class__, self.thrift_spec))) - return - oprot.writeStruct(self, self.thrift_spec) - -class TExceptionBase(Exception): - # old style class so python2.4 can raise exceptions derived from this - # This can't inherit from TBase because of that limitation. - __slots__ = [] - - __repr__ = TBase.__repr__.im_func - __eq__ = TBase.__eq__.im_func - __ne__ = TBase.__ne__.im_func - read = TBase.read.im_func - write = TBase.write.im_func - diff --git a/anknotes/thrift/protocol/TBinaryProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/protocol/TBinaryProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 50c6aa8..0000000 --- a/anknotes/thrift/protocol/TBinaryProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,259 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from TProtocol import * -from struct import pack, unpack - -class TBinaryProtocol(TProtocolBase): - - """Binary implementation of the Thrift protocol driver.""" - - # NastyHaxx. Python 2.4+ on 32-bit machines forces hex constants to be - # positive, converting this into a long. If we hardcode the int value - # instead it'll stay in 32 bit-land. - - # VERSION_MASK = 0xffff0000 - VERSION_MASK = -65536 - - # VERSION_1 = 0x80010000 - VERSION_1 = -2147418112 - - TYPE_MASK = 0x000000ff - - def __init__(self, trans, strictRead=False, strictWrite=True): - TProtocolBase.__init__(self, trans) - self.strictRead = strictRead - self.strictWrite = strictWrite - - def writeMessageBegin(self, name, type, seqid): - if self.strictWrite: - self.writeI32(TBinaryProtocol.VERSION_1 | type) - self.writeString(name) - self.writeI32(seqid) - else: - self.writeString(name) - self.writeByte(type) - self.writeI32(seqid) - - def writeMessageEnd(self): - pass - - def writeStructBegin(self, name): - pass - - def writeStructEnd(self): - pass - - def writeFieldBegin(self, name, type, id): - self.writeByte(type) - self.writeI16(id) - - def writeFieldEnd(self): - pass - - def writeFieldStop(self): - self.writeByte(TType.STOP); - - def writeMapBegin(self, ktype, vtype, size): - self.writeByte(ktype) - self.writeByte(vtype) - self.writeI32(size) - - def writeMapEnd(self): - pass - - def writeListBegin(self, etype, size): - self.writeByte(etype) - self.writeI32(size) - - def writeListEnd(self): - pass - - def writeSetBegin(self, etype, size): - self.writeByte(etype) - self.writeI32(size) - - def writeSetEnd(self): - pass - - def writeBool(self, bool): - if bool: - self.writeByte(1) - else: - self.writeByte(0) - - def writeByte(self, byte): - buff = pack("!b", byte) - self.trans.write(buff) - - def writeI16(self, i16): - buff = pack("!h", i16) - self.trans.write(buff) - - def writeI32(self, i32): - buff = pack("!i", i32) - self.trans.write(buff) - - def writeI64(self, i64): - buff = pack("!q", i64) - self.trans.write(buff) - - def writeDouble(self, dub): - buff = pack("!d", dub) - self.trans.write(buff) - - def writeString(self, str): - self.writeI32(len(str)) - self.trans.write(str) - - def readMessageBegin(self): - sz = self.readI32() - if sz < 0: - version = sz & TBinaryProtocol.VERSION_MASK - if version != TBinaryProtocol.VERSION_1: - raise TProtocolException(type=TProtocolException.BAD_VERSION, message='Bad version in readMessageBegin: %d' % (sz)) - type = sz & TBinaryProtocol.TYPE_MASK - name = self.readString() - seqid = self.readI32() - else: - if self.strictRead: - raise TProtocolException(type=TProtocolException.BAD_VERSION, message='No protocol version header') - name = self.trans.readAll(sz) - type = self.readByte() - seqid = self.readI32() - return (name, type, seqid) - - def readMessageEnd(self): - pass - - def readStructBegin(self): - pass - - def readStructEnd(self): - pass - - def readFieldBegin(self): - type = self.readByte() - if type == TType.STOP: - return (None, type, 0) - id = self.readI16() - return (None, type, id) - - def readFieldEnd(self): - pass - - def readMapBegin(self): - ktype = self.readByte() - vtype = self.readByte() - size = self.readI32() - return (ktype, vtype, size) - - def readMapEnd(self): - pass - - def readListBegin(self): - etype = self.readByte() - size = self.readI32() - return (etype, size) - - def readListEnd(self): - pass - - def readSetBegin(self): - etype = self.readByte() - size = self.readI32() - return (etype, size) - - def readSetEnd(self): - pass - - def readBool(self): - byte = self.readByte() - if byte == 0: - return False - return True - - def readByte(self): - buff = self.trans.readAll(1) - val, = unpack('!b', buff) - return val - - def readI16(self): - buff = self.trans.readAll(2) - val, = unpack('!h', buff) - return val - - def readI32(self): - buff = self.trans.readAll(4) - val, = unpack('!i', buff) - return val - - def readI64(self): - buff = self.trans.readAll(8) - val, = unpack('!q', buff) - return val - - def readDouble(self): - buff = self.trans.readAll(8) - val, = unpack('!d', buff) - return val - - def readString(self): - len = self.readI32() - str = self.trans.readAll(len) - return str - - -class TBinaryProtocolFactory: - def __init__(self, strictRead=False, strictWrite=True): - self.strictRead = strictRead - self.strictWrite = strictWrite - - def getProtocol(self, trans): - prot = TBinaryProtocol(trans, self.strictRead, self.strictWrite) - return prot - - -class TBinaryProtocolAccelerated(TBinaryProtocol): - - """C-Accelerated version of TBinaryProtocol. - - This class does not override any of TBinaryProtocol's methods, - but the generated code recognizes it directly and will call into - our C module to do the encoding, bypassing this object entirely. - We inherit from TBinaryProtocol so that the normal TBinaryProtocol - encoding can happen if the fastbinary module doesn't work for some - reason. (TODO(dreiss): Make this happen sanely in more cases.) - - In order to take advantage of the C module, just use - TBinaryProtocolAccelerated instead of TBinaryProtocol. - - NOTE: This code was contributed by an external developer. - The internal Thrift team has reviewed and tested it, - but we cannot guarantee that it is production-ready. - Please feel free to report bugs and/or success stories - to the public mailing list. - """ - - pass - - -class TBinaryProtocolAcceleratedFactory: - def getProtocol(self, trans): - return TBinaryProtocolAccelerated(trans) diff --git a/anknotes/thrift/protocol/TBinaryProtocol.py~HEAD b/anknotes/thrift/protocol/TBinaryProtocol.py~HEAD deleted file mode 100644 index 50c6aa8..0000000 --- a/anknotes/thrift/protocol/TBinaryProtocol.py~HEAD +++ /dev/null @@ -1,259 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from TProtocol import * -from struct import pack, unpack - -class TBinaryProtocol(TProtocolBase): - - """Binary implementation of the Thrift protocol driver.""" - - # NastyHaxx. Python 2.4+ on 32-bit machines forces hex constants to be - # positive, converting this into a long. If we hardcode the int value - # instead it'll stay in 32 bit-land. - - # VERSION_MASK = 0xffff0000 - VERSION_MASK = -65536 - - # VERSION_1 = 0x80010000 - VERSION_1 = -2147418112 - - TYPE_MASK = 0x000000ff - - def __init__(self, trans, strictRead=False, strictWrite=True): - TProtocolBase.__init__(self, trans) - self.strictRead = strictRead - self.strictWrite = strictWrite - - def writeMessageBegin(self, name, type, seqid): - if self.strictWrite: - self.writeI32(TBinaryProtocol.VERSION_1 | type) - self.writeString(name) - self.writeI32(seqid) - else: - self.writeString(name) - self.writeByte(type) - self.writeI32(seqid) - - def writeMessageEnd(self): - pass - - def writeStructBegin(self, name): - pass - - def writeStructEnd(self): - pass - - def writeFieldBegin(self, name, type, id): - self.writeByte(type) - self.writeI16(id) - - def writeFieldEnd(self): - pass - - def writeFieldStop(self): - self.writeByte(TType.STOP); - - def writeMapBegin(self, ktype, vtype, size): - self.writeByte(ktype) - self.writeByte(vtype) - self.writeI32(size) - - def writeMapEnd(self): - pass - - def writeListBegin(self, etype, size): - self.writeByte(etype) - self.writeI32(size) - - def writeListEnd(self): - pass - - def writeSetBegin(self, etype, size): - self.writeByte(etype) - self.writeI32(size) - - def writeSetEnd(self): - pass - - def writeBool(self, bool): - if bool: - self.writeByte(1) - else: - self.writeByte(0) - - def writeByte(self, byte): - buff = pack("!b", byte) - self.trans.write(buff) - - def writeI16(self, i16): - buff = pack("!h", i16) - self.trans.write(buff) - - def writeI32(self, i32): - buff = pack("!i", i32) - self.trans.write(buff) - - def writeI64(self, i64): - buff = pack("!q", i64) - self.trans.write(buff) - - def writeDouble(self, dub): - buff = pack("!d", dub) - self.trans.write(buff) - - def writeString(self, str): - self.writeI32(len(str)) - self.trans.write(str) - - def readMessageBegin(self): - sz = self.readI32() - if sz < 0: - version = sz & TBinaryProtocol.VERSION_MASK - if version != TBinaryProtocol.VERSION_1: - raise TProtocolException(type=TProtocolException.BAD_VERSION, message='Bad version in readMessageBegin: %d' % (sz)) - type = sz & TBinaryProtocol.TYPE_MASK - name = self.readString() - seqid = self.readI32() - else: - if self.strictRead: - raise TProtocolException(type=TProtocolException.BAD_VERSION, message='No protocol version header') - name = self.trans.readAll(sz) - type = self.readByte() - seqid = self.readI32() - return (name, type, seqid) - - def readMessageEnd(self): - pass - - def readStructBegin(self): - pass - - def readStructEnd(self): - pass - - def readFieldBegin(self): - type = self.readByte() - if type == TType.STOP: - return (None, type, 0) - id = self.readI16() - return (None, type, id) - - def readFieldEnd(self): - pass - - def readMapBegin(self): - ktype = self.readByte() - vtype = self.readByte() - size = self.readI32() - return (ktype, vtype, size) - - def readMapEnd(self): - pass - - def readListBegin(self): - etype = self.readByte() - size = self.readI32() - return (etype, size) - - def readListEnd(self): - pass - - def readSetBegin(self): - etype = self.readByte() - size = self.readI32() - return (etype, size) - - def readSetEnd(self): - pass - - def readBool(self): - byte = self.readByte() - if byte == 0: - return False - return True - - def readByte(self): - buff = self.trans.readAll(1) - val, = unpack('!b', buff) - return val - - def readI16(self): - buff = self.trans.readAll(2) - val, = unpack('!h', buff) - return val - - def readI32(self): - buff = self.trans.readAll(4) - val, = unpack('!i', buff) - return val - - def readI64(self): - buff = self.trans.readAll(8) - val, = unpack('!q', buff) - return val - - def readDouble(self): - buff = self.trans.readAll(8) - val, = unpack('!d', buff) - return val - - def readString(self): - len = self.readI32() - str = self.trans.readAll(len) - return str - - -class TBinaryProtocolFactory: - def __init__(self, strictRead=False, strictWrite=True): - self.strictRead = strictRead - self.strictWrite = strictWrite - - def getProtocol(self, trans): - prot = TBinaryProtocol(trans, self.strictRead, self.strictWrite) - return prot - - -class TBinaryProtocolAccelerated(TBinaryProtocol): - - """C-Accelerated version of TBinaryProtocol. - - This class does not override any of TBinaryProtocol's methods, - but the generated code recognizes it directly and will call into - our C module to do the encoding, bypassing this object entirely. - We inherit from TBinaryProtocol so that the normal TBinaryProtocol - encoding can happen if the fastbinary module doesn't work for some - reason. (TODO(dreiss): Make this happen sanely in more cases.) - - In order to take advantage of the C module, just use - TBinaryProtocolAccelerated instead of TBinaryProtocol. - - NOTE: This code was contributed by an external developer. - The internal Thrift team has reviewed and tested it, - but we cannot guarantee that it is production-ready. - Please feel free to report bugs and/or success stories - to the public mailing list. - """ - - pass - - -class TBinaryProtocolAcceleratedFactory: - def getProtocol(self, trans): - return TBinaryProtocolAccelerated(trans) diff --git a/anknotes/thrift/protocol/TCompactProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/protocol/TCompactProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 016a331..0000000 --- a/anknotes/thrift/protocol/TCompactProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,395 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from TProtocol import * -from struct import pack, unpack - -__all__ = ['TCompactProtocol', 'TCompactProtocolFactory'] - -CLEAR = 0 -FIELD_WRITE = 1 -VALUE_WRITE = 2 -CONTAINER_WRITE = 3 -BOOL_WRITE = 4 -FIELD_READ = 5 -CONTAINER_READ = 6 -VALUE_READ = 7 -BOOL_READ = 8 - -def make_helper(v_from, container): - def helper(func): - def nested(self, *args, **kwargs): - assert self.state in (v_from, container), (self.state, v_from, container) - return func(self, *args, **kwargs) - return nested - return helper -writer = make_helper(VALUE_WRITE, CONTAINER_WRITE) -reader = make_helper(VALUE_READ, CONTAINER_READ) - -def makeZigZag(n, bits): - return (n << 1) ^ (n >> (bits - 1)) - -def fromZigZag(n): - return (n >> 1) ^ -(n & 1) - -def writeVarint(trans, n): - out = [] - while True: - if n & ~0x7f == 0: - out.append(n) - break - else: - out.append((n & 0xff) | 0x80) - n = n >> 7 - trans.write(''.join(map(chr, out))) - -def readVarint(trans): - result = 0 - shift = 0 - while True: - x = trans.readAll(1) - byte = ord(x) - result |= (byte & 0x7f) << shift - if byte >> 7 == 0: - return result - shift += 7 - -class CompactType: - STOP = 0x00 - TRUE = 0x01 - FALSE = 0x02 - BYTE = 0x03 - I16 = 0x04 - I32 = 0x05 - I64 = 0x06 - DOUBLE = 0x07 - BINARY = 0x08 - LIST = 0x09 - SET = 0x0A - MAP = 0x0B - STRUCT = 0x0C - -CTYPES = {TType.STOP: CompactType.STOP, - TType.BOOL: CompactType.TRUE, # used for collection - TType.BYTE: CompactType.BYTE, - TType.I16: CompactType.I16, - TType.I32: CompactType.I32, - TType.I64: CompactType.I64, - TType.DOUBLE: CompactType.DOUBLE, - TType.STRING: CompactType.BINARY, - TType.STRUCT: CompactType.STRUCT, - TType.LIST: CompactType.LIST, - TType.SET: CompactType.SET, - TType.MAP: CompactType.MAP - } - -TTYPES = {} -for k, v in CTYPES.items(): - TTYPES[v] = k -TTYPES[CompactType.FALSE] = TType.BOOL -del k -del v - -class TCompactProtocol(TProtocolBase): - "Compact implementation of the Thrift protocol driver." - - PROTOCOL_ID = 0x82 - VERSION = 1 - VERSION_MASK = 0x1f - TYPE_MASK = 0xe0 - TYPE_SHIFT_AMOUNT = 5 - - def __init__(self, trans): - TProtocolBase.__init__(self, trans) - self.state = CLEAR - self.__last_fid = 0 - self.__bool_fid = None - self.__bool_value = None - self.__structs = [] - self.__containers = [] - - def __writeVarint(self, n): - writeVarint(self.trans, n) - - def writeMessageBegin(self, name, type, seqid): - assert self.state == CLEAR - self.__writeUByte(self.PROTOCOL_ID) - self.__writeUByte(self.VERSION | (type << self.TYPE_SHIFT_AMOUNT)) - self.__writeVarint(seqid) - self.__writeString(name) - self.state = VALUE_WRITE - - def writeMessageEnd(self): - assert self.state == VALUE_WRITE - self.state = CLEAR - - def writeStructBegin(self, name): - assert self.state in (CLEAR, CONTAINER_WRITE, VALUE_WRITE), self.state - self.__structs.append((self.state, self.__last_fid)) - self.state = FIELD_WRITE - self.__last_fid = 0 - - def writeStructEnd(self): - assert self.state == FIELD_WRITE - self.state, self.__last_fid = self.__structs.pop() - - def writeFieldStop(self): - self.__writeByte(0) - - def __writeFieldHeader(self, type, fid): - delta = fid - self.__last_fid - if 0 < delta <= 15: - self.__writeUByte(delta << 4 | type) - else: - self.__writeByte(type) - self.__writeI16(fid) - self.__last_fid = fid - - def writeFieldBegin(self, name, type, fid): - assert self.state == FIELD_WRITE, self.state - if type == TType.BOOL: - self.state = BOOL_WRITE - self.__bool_fid = fid - else: - self.state = VALUE_WRITE - self.__writeFieldHeader(CTYPES[type], fid) - - def writeFieldEnd(self): - assert self.state in (VALUE_WRITE, BOOL_WRITE), self.state - self.state = FIELD_WRITE - - def __writeUByte(self, byte): - self.trans.write(pack('!B', byte)) - - def __writeByte(self, byte): - self.trans.write(pack('!b', byte)) - - def __writeI16(self, i16): - self.__writeVarint(makeZigZag(i16, 16)) - - def __writeSize(self, i32): - self.__writeVarint(i32) - - def writeCollectionBegin(self, etype, size): - assert self.state in (VALUE_WRITE, CONTAINER_WRITE), self.state - if size <= 14: - self.__writeUByte(size << 4 | CTYPES[etype]) - else: - self.__writeUByte(0xf0 | CTYPES[etype]) - self.__writeSize(size) - self.__containers.append(self.state) - self.state = CONTAINER_WRITE - writeSetBegin = writeCollectionBegin - writeListBegin = writeCollectionBegin - - def writeMapBegin(self, ktype, vtype, size): - assert self.state in (VALUE_WRITE, CONTAINER_WRITE), self.state - if size == 0: - self.__writeByte(0) - else: - self.__writeSize(size) - self.__writeUByte(CTYPES[ktype] << 4 | CTYPES[vtype]) - self.__containers.append(self.state) - self.state = CONTAINER_WRITE - - def writeCollectionEnd(self): - assert self.state == CONTAINER_WRITE, self.state - self.state = self.__containers.pop() - writeMapEnd = writeCollectionEnd - writeSetEnd = writeCollectionEnd - writeListEnd = writeCollectionEnd - - def writeBool(self, bool): - if self.state == BOOL_WRITE: - if bool: - ctype = CompactType.TRUE - else: - ctype = CompactType.FALSE - self.__writeFieldHeader(ctype, self.__bool_fid) - elif self.state == CONTAINER_WRITE: - if bool: - self.__writeByte(CompactType.TRUE) - else: - self.__writeByte(CompactType.FALSE) - else: - raise AssertionError, "Invalid state in compact protocol" - - writeByte = writer(__writeByte) - writeI16 = writer(__writeI16) - - @writer - def writeI32(self, i32): - self.__writeVarint(makeZigZag(i32, 32)) - - @writer - def writeI64(self, i64): - self.__writeVarint(makeZigZag(i64, 64)) - - @writer - def writeDouble(self, dub): - self.trans.write(pack('!d', dub)) - - def __writeString(self, s): - self.__writeSize(len(s)) - self.trans.write(s) - writeString = writer(__writeString) - - def readFieldBegin(self): - assert self.state == FIELD_READ, self.state - type = self.__readUByte() - if type & 0x0f == TType.STOP: - return (None, 0, 0) - delta = type >> 4 - if delta == 0: - fid = self.__readI16() - else: - fid = self.__last_fid + delta - self.__last_fid = fid - type = type & 0x0f - if type == CompactType.TRUE: - self.state = BOOL_READ - self.__bool_value = True - elif type == CompactType.FALSE: - self.state = BOOL_READ - self.__bool_value = False - else: - self.state = VALUE_READ - return (None, self.__getTType(type), fid) - - def readFieldEnd(self): - assert self.state in (VALUE_READ, BOOL_READ), self.state - self.state = FIELD_READ - - def __readUByte(self): - result, = unpack('!B', self.trans.readAll(1)) - return result - - def __readByte(self): - result, = unpack('!b', self.trans.readAll(1)) - return result - - def __readVarint(self): - return readVarint(self.trans) - - def __readZigZag(self): - return fromZigZag(self.__readVarint()) - - def __readSize(self): - result = self.__readVarint() - if result < 0: - raise TException("Length < 0") - return result - - def readMessageBegin(self): - assert self.state == CLEAR - proto_id = self.__readUByte() - if proto_id != self.PROTOCOL_ID: - raise TProtocolException(TProtocolException.BAD_VERSION, - 'Bad protocol id in the message: %d' % proto_id) - ver_type = self.__readUByte() - type = (ver_type & self.TYPE_MASK) >> self.TYPE_SHIFT_AMOUNT - version = ver_type & self.VERSION_MASK - if version != self.VERSION: - raise TProtocolException(TProtocolException.BAD_VERSION, - 'Bad version: %d (expect %d)' % (version, self.VERSION)) - seqid = self.__readVarint() - name = self.__readString() - return (name, type, seqid) - - def readMessageEnd(self): - assert self.state == CLEAR - assert len(self.__structs) == 0 - - def readStructBegin(self): - assert self.state in (CLEAR, CONTAINER_READ, VALUE_READ), self.state - self.__structs.append((self.state, self.__last_fid)) - self.state = FIELD_READ - self.__last_fid = 0 - - def readStructEnd(self): - assert self.state == FIELD_READ - self.state, self.__last_fid = self.__structs.pop() - - def readCollectionBegin(self): - assert self.state in (VALUE_READ, CONTAINER_READ), self.state - size_type = self.__readUByte() - size = size_type >> 4 - type = self.__getTType(size_type) - if size == 15: - size = self.__readSize() - self.__containers.append(self.state) - self.state = CONTAINER_READ - return type, size - readSetBegin = readCollectionBegin - readListBegin = readCollectionBegin - - def readMapBegin(self): - assert self.state in (VALUE_READ, CONTAINER_READ), self.state - size = self.__readSize() - types = 0 - if size > 0: - types = self.__readUByte() - vtype = self.__getTType(types) - ktype = self.__getTType(types >> 4) - self.__containers.append(self.state) - self.state = CONTAINER_READ - return (ktype, vtype, size) - - def readCollectionEnd(self): - assert self.state == CONTAINER_READ, self.state - self.state = self.__containers.pop() - readSetEnd = readCollectionEnd - readListEnd = readCollectionEnd - readMapEnd = readCollectionEnd - - def readBool(self): - if self.state == BOOL_READ: - return self.__bool_value == CompactType.TRUE - elif self.state == CONTAINER_READ: - return self.__readByte() == CompactType.TRUE - else: - raise AssertionError, "Invalid state in compact protocol: %d" % self.state - - readByte = reader(__readByte) - __readI16 = __readZigZag - readI16 = reader(__readZigZag) - readI32 = reader(__readZigZag) - readI64 = reader(__readZigZag) - - @reader - def readDouble(self): - buff = self.trans.readAll(8) - val, = unpack('!d', buff) - return val - - def __readString(self): - len = self.__readSize() - return self.trans.readAll(len) - readString = reader(__readString) - - def __getTType(self, byte): - return TTYPES[byte & 0x0f] - - -class TCompactProtocolFactory: - def __init__(self): - pass - - def getProtocol(self, trans): - return TCompactProtocol(trans) diff --git a/anknotes/thrift/protocol/TCompactProtocol.py~HEAD b/anknotes/thrift/protocol/TCompactProtocol.py~HEAD deleted file mode 100644 index 016a331..0000000 --- a/anknotes/thrift/protocol/TCompactProtocol.py~HEAD +++ /dev/null @@ -1,395 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from TProtocol import * -from struct import pack, unpack - -__all__ = ['TCompactProtocol', 'TCompactProtocolFactory'] - -CLEAR = 0 -FIELD_WRITE = 1 -VALUE_WRITE = 2 -CONTAINER_WRITE = 3 -BOOL_WRITE = 4 -FIELD_READ = 5 -CONTAINER_READ = 6 -VALUE_READ = 7 -BOOL_READ = 8 - -def make_helper(v_from, container): - def helper(func): - def nested(self, *args, **kwargs): - assert self.state in (v_from, container), (self.state, v_from, container) - return func(self, *args, **kwargs) - return nested - return helper -writer = make_helper(VALUE_WRITE, CONTAINER_WRITE) -reader = make_helper(VALUE_READ, CONTAINER_READ) - -def makeZigZag(n, bits): - return (n << 1) ^ (n >> (bits - 1)) - -def fromZigZag(n): - return (n >> 1) ^ -(n & 1) - -def writeVarint(trans, n): - out = [] - while True: - if n & ~0x7f == 0: - out.append(n) - break - else: - out.append((n & 0xff) | 0x80) - n = n >> 7 - trans.write(''.join(map(chr, out))) - -def readVarint(trans): - result = 0 - shift = 0 - while True: - x = trans.readAll(1) - byte = ord(x) - result |= (byte & 0x7f) << shift - if byte >> 7 == 0: - return result - shift += 7 - -class CompactType: - STOP = 0x00 - TRUE = 0x01 - FALSE = 0x02 - BYTE = 0x03 - I16 = 0x04 - I32 = 0x05 - I64 = 0x06 - DOUBLE = 0x07 - BINARY = 0x08 - LIST = 0x09 - SET = 0x0A - MAP = 0x0B - STRUCT = 0x0C - -CTYPES = {TType.STOP: CompactType.STOP, - TType.BOOL: CompactType.TRUE, # used for collection - TType.BYTE: CompactType.BYTE, - TType.I16: CompactType.I16, - TType.I32: CompactType.I32, - TType.I64: CompactType.I64, - TType.DOUBLE: CompactType.DOUBLE, - TType.STRING: CompactType.BINARY, - TType.STRUCT: CompactType.STRUCT, - TType.LIST: CompactType.LIST, - TType.SET: CompactType.SET, - TType.MAP: CompactType.MAP - } - -TTYPES = {} -for k, v in CTYPES.items(): - TTYPES[v] = k -TTYPES[CompactType.FALSE] = TType.BOOL -del k -del v - -class TCompactProtocol(TProtocolBase): - "Compact implementation of the Thrift protocol driver." - - PROTOCOL_ID = 0x82 - VERSION = 1 - VERSION_MASK = 0x1f - TYPE_MASK = 0xe0 - TYPE_SHIFT_AMOUNT = 5 - - def __init__(self, trans): - TProtocolBase.__init__(self, trans) - self.state = CLEAR - self.__last_fid = 0 - self.__bool_fid = None - self.__bool_value = None - self.__structs = [] - self.__containers = [] - - def __writeVarint(self, n): - writeVarint(self.trans, n) - - def writeMessageBegin(self, name, type, seqid): - assert self.state == CLEAR - self.__writeUByte(self.PROTOCOL_ID) - self.__writeUByte(self.VERSION | (type << self.TYPE_SHIFT_AMOUNT)) - self.__writeVarint(seqid) - self.__writeString(name) - self.state = VALUE_WRITE - - def writeMessageEnd(self): - assert self.state == VALUE_WRITE - self.state = CLEAR - - def writeStructBegin(self, name): - assert self.state in (CLEAR, CONTAINER_WRITE, VALUE_WRITE), self.state - self.__structs.append((self.state, self.__last_fid)) - self.state = FIELD_WRITE - self.__last_fid = 0 - - def writeStructEnd(self): - assert self.state == FIELD_WRITE - self.state, self.__last_fid = self.__structs.pop() - - def writeFieldStop(self): - self.__writeByte(0) - - def __writeFieldHeader(self, type, fid): - delta = fid - self.__last_fid - if 0 < delta <= 15: - self.__writeUByte(delta << 4 | type) - else: - self.__writeByte(type) - self.__writeI16(fid) - self.__last_fid = fid - - def writeFieldBegin(self, name, type, fid): - assert self.state == FIELD_WRITE, self.state - if type == TType.BOOL: - self.state = BOOL_WRITE - self.__bool_fid = fid - else: - self.state = VALUE_WRITE - self.__writeFieldHeader(CTYPES[type], fid) - - def writeFieldEnd(self): - assert self.state in (VALUE_WRITE, BOOL_WRITE), self.state - self.state = FIELD_WRITE - - def __writeUByte(self, byte): - self.trans.write(pack('!B', byte)) - - def __writeByte(self, byte): - self.trans.write(pack('!b', byte)) - - def __writeI16(self, i16): - self.__writeVarint(makeZigZag(i16, 16)) - - def __writeSize(self, i32): - self.__writeVarint(i32) - - def writeCollectionBegin(self, etype, size): - assert self.state in (VALUE_WRITE, CONTAINER_WRITE), self.state - if size <= 14: - self.__writeUByte(size << 4 | CTYPES[etype]) - else: - self.__writeUByte(0xf0 | CTYPES[etype]) - self.__writeSize(size) - self.__containers.append(self.state) - self.state = CONTAINER_WRITE - writeSetBegin = writeCollectionBegin - writeListBegin = writeCollectionBegin - - def writeMapBegin(self, ktype, vtype, size): - assert self.state in (VALUE_WRITE, CONTAINER_WRITE), self.state - if size == 0: - self.__writeByte(0) - else: - self.__writeSize(size) - self.__writeUByte(CTYPES[ktype] << 4 | CTYPES[vtype]) - self.__containers.append(self.state) - self.state = CONTAINER_WRITE - - def writeCollectionEnd(self): - assert self.state == CONTAINER_WRITE, self.state - self.state = self.__containers.pop() - writeMapEnd = writeCollectionEnd - writeSetEnd = writeCollectionEnd - writeListEnd = writeCollectionEnd - - def writeBool(self, bool): - if self.state == BOOL_WRITE: - if bool: - ctype = CompactType.TRUE - else: - ctype = CompactType.FALSE - self.__writeFieldHeader(ctype, self.__bool_fid) - elif self.state == CONTAINER_WRITE: - if bool: - self.__writeByte(CompactType.TRUE) - else: - self.__writeByte(CompactType.FALSE) - else: - raise AssertionError, "Invalid state in compact protocol" - - writeByte = writer(__writeByte) - writeI16 = writer(__writeI16) - - @writer - def writeI32(self, i32): - self.__writeVarint(makeZigZag(i32, 32)) - - @writer - def writeI64(self, i64): - self.__writeVarint(makeZigZag(i64, 64)) - - @writer - def writeDouble(self, dub): - self.trans.write(pack('!d', dub)) - - def __writeString(self, s): - self.__writeSize(len(s)) - self.trans.write(s) - writeString = writer(__writeString) - - def readFieldBegin(self): - assert self.state == FIELD_READ, self.state - type = self.__readUByte() - if type & 0x0f == TType.STOP: - return (None, 0, 0) - delta = type >> 4 - if delta == 0: - fid = self.__readI16() - else: - fid = self.__last_fid + delta - self.__last_fid = fid - type = type & 0x0f - if type == CompactType.TRUE: - self.state = BOOL_READ - self.__bool_value = True - elif type == CompactType.FALSE: - self.state = BOOL_READ - self.__bool_value = False - else: - self.state = VALUE_READ - return (None, self.__getTType(type), fid) - - def readFieldEnd(self): - assert self.state in (VALUE_READ, BOOL_READ), self.state - self.state = FIELD_READ - - def __readUByte(self): - result, = unpack('!B', self.trans.readAll(1)) - return result - - def __readByte(self): - result, = unpack('!b', self.trans.readAll(1)) - return result - - def __readVarint(self): - return readVarint(self.trans) - - def __readZigZag(self): - return fromZigZag(self.__readVarint()) - - def __readSize(self): - result = self.__readVarint() - if result < 0: - raise TException("Length < 0") - return result - - def readMessageBegin(self): - assert self.state == CLEAR - proto_id = self.__readUByte() - if proto_id != self.PROTOCOL_ID: - raise TProtocolException(TProtocolException.BAD_VERSION, - 'Bad protocol id in the message: %d' % proto_id) - ver_type = self.__readUByte() - type = (ver_type & self.TYPE_MASK) >> self.TYPE_SHIFT_AMOUNT - version = ver_type & self.VERSION_MASK - if version != self.VERSION: - raise TProtocolException(TProtocolException.BAD_VERSION, - 'Bad version: %d (expect %d)' % (version, self.VERSION)) - seqid = self.__readVarint() - name = self.__readString() - return (name, type, seqid) - - def readMessageEnd(self): - assert self.state == CLEAR - assert len(self.__structs) == 0 - - def readStructBegin(self): - assert self.state in (CLEAR, CONTAINER_READ, VALUE_READ), self.state - self.__structs.append((self.state, self.__last_fid)) - self.state = FIELD_READ - self.__last_fid = 0 - - def readStructEnd(self): - assert self.state == FIELD_READ - self.state, self.__last_fid = self.__structs.pop() - - def readCollectionBegin(self): - assert self.state in (VALUE_READ, CONTAINER_READ), self.state - size_type = self.__readUByte() - size = size_type >> 4 - type = self.__getTType(size_type) - if size == 15: - size = self.__readSize() - self.__containers.append(self.state) - self.state = CONTAINER_READ - return type, size - readSetBegin = readCollectionBegin - readListBegin = readCollectionBegin - - def readMapBegin(self): - assert self.state in (VALUE_READ, CONTAINER_READ), self.state - size = self.__readSize() - types = 0 - if size > 0: - types = self.__readUByte() - vtype = self.__getTType(types) - ktype = self.__getTType(types >> 4) - self.__containers.append(self.state) - self.state = CONTAINER_READ - return (ktype, vtype, size) - - def readCollectionEnd(self): - assert self.state == CONTAINER_READ, self.state - self.state = self.__containers.pop() - readSetEnd = readCollectionEnd - readListEnd = readCollectionEnd - readMapEnd = readCollectionEnd - - def readBool(self): - if self.state == BOOL_READ: - return self.__bool_value == CompactType.TRUE - elif self.state == CONTAINER_READ: - return self.__readByte() == CompactType.TRUE - else: - raise AssertionError, "Invalid state in compact protocol: %d" % self.state - - readByte = reader(__readByte) - __readI16 = __readZigZag - readI16 = reader(__readZigZag) - readI32 = reader(__readZigZag) - readI64 = reader(__readZigZag) - - @reader - def readDouble(self): - buff = self.trans.readAll(8) - val, = unpack('!d', buff) - return val - - def __readString(self): - len = self.__readSize() - return self.trans.readAll(len) - readString = reader(__readString) - - def __getTType(self, byte): - return TTYPES[byte & 0x0f] - - -class TCompactProtocolFactory: - def __init__(self): - pass - - def getProtocol(self, trans): - return TCompactProtocol(trans) diff --git a/anknotes/thrift/protocol/TProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/protocol/TProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index d6d3938..0000000 --- a/anknotes/thrift/protocol/TProtocol.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,404 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from anknotes.thrift.Thrift import * - -class TProtocolException(TException): - - """Custom Protocol Exception class""" - - UNKNOWN = 0 - INVALID_DATA = 1 - NEGATIVE_SIZE = 2 - SIZE_LIMIT = 3 - BAD_VERSION = 4 - - def __init__(self, type=UNKNOWN, message=None): - TException.__init__(self, message) - self.type = type - -class TProtocolBase: - - """Base class for Thrift protocol driver.""" - - def __init__(self, trans): - self.trans = trans - - def writeMessageBegin(self, name, type, seqid): - pass - - def writeMessageEnd(self): - pass - - def writeStructBegin(self, name): - pass - - def writeStructEnd(self): - pass - - def writeFieldBegin(self, name, type, id): - pass - - def writeFieldEnd(self): - pass - - def writeFieldStop(self): - pass - - def writeMapBegin(self, ktype, vtype, size): - pass - - def writeMapEnd(self): - pass - - def writeListBegin(self, etype, size): - pass - - def writeListEnd(self): - pass - - def writeSetBegin(self, etype, size): - pass - - def writeSetEnd(self): - pass - - def writeBool(self, bool): - pass - - def writeByte(self, byte): - pass - - def writeI16(self, i16): - pass - - def writeI32(self, i32): - pass - - def writeI64(self, i64): - pass - - def writeDouble(self, dub): - pass - - def writeString(self, str): - pass - - def readMessageBegin(self): - pass - - def readMessageEnd(self): - pass - - def readStructBegin(self): - pass - - def readStructEnd(self): - pass - - def readFieldBegin(self): - pass - - def readFieldEnd(self): - pass - - def readMapBegin(self): - pass - - def readMapEnd(self): - pass - - def readListBegin(self): - pass - - def readListEnd(self): - pass - - def readSetBegin(self): - pass - - def readSetEnd(self): - pass - - def readBool(self): - pass - - def readByte(self): - pass - - def readI16(self): - pass - - def readI32(self): - pass - - def readI64(self): - pass - - def readDouble(self): - pass - - def readString(self): - pass - - def skip(self, type): - if type == TType.STOP: - return - elif type == TType.BOOL: - self.readBool() - elif type == TType.BYTE: - self.readByte() - elif type == TType.I16: - self.readI16() - elif type == TType.I32: - self.readI32() - elif type == TType.I64: - self.readI64() - elif type == TType.DOUBLE: - self.readDouble() - elif type == TType.STRING: - self.readString() - elif type == TType.STRUCT: - name = self.readStructBegin() - while True: - (name, type, id) = self.readFieldBegin() - if type == TType.STOP: - break - self.skip(type) - self.readFieldEnd() - self.readStructEnd() - elif type == TType.MAP: - (ktype, vtype, size) = self.readMapBegin() - for i in range(size): - self.skip(ktype) - self.skip(vtype) - self.readMapEnd() - elif type == TType.SET: - (etype, size) = self.readSetBegin() - for i in range(size): - self.skip(etype) - self.readSetEnd() - elif type == TType.LIST: - (etype, size) = self.readListBegin() - for i in range(size): - self.skip(etype) - self.readListEnd() - - # tuple of: ( 'reader method' name, is_container boolean, 'writer_method' name ) - _TTYPE_HANDLERS = ( - (None, None, False), # 0 == TType,STOP - (None, None, False), # 1 == TType.VOID # TODO: handle void? - ('readBool', 'writeBool', False), # 2 == TType.BOOL - ('readByte', 'writeByte', False), # 3 == TType.BYTE and I08 - ('readDouble', 'writeDouble', False), # 4 == TType.DOUBLE - (None, None, False), # 5, undefined - ('readI16', 'writeI16', False), # 6 == TType.I16 - (None, None, False), # 7, undefined - ('readI32', 'writeI32', False), # 8 == TType.I32 - (None, None, False), # 9, undefined - ('readI64', 'writeI64', False), # 10 == TType.I64 - ('readString', 'writeString', False), # 11 == TType.STRING and UTF7 - ('readContainerStruct', 'writeContainerStruct', True), # 12 == TType.STRUCT - ('readContainerMap', 'writeContainerMap', True), # 13 == TType.MAP - ('readContainerSet', 'writeContainerSet', True), # 14 == TType.SET - ('readContainerList', 'writeContainerList', True), # 15 == TType.LIST - (None, None, False), # 16 == TType.UTF8 # TODO: handle utf8 types? - (None, None, False)# 17 == TType.UTF16 # TODO: handle utf16 types? - ) - - def readFieldByTType(self, ttype, spec): - try: - (r_handler, w_handler, is_container) = self._TTYPE_HANDLERS[ttype] - except IndexError: - raise TProtocolException(type=TProtocolException.INVALID_DATA, - message='Invalid field type %d' % (ttype)) - if r_handler is None: - raise TProtocolException(type=TProtocolException.INVALID_DATA, - message='Invalid field type %d' % (ttype)) - reader = getattr(self, r_handler) - if not is_container: - return reader() - return reader(spec) - - def readContainerList(self, spec): - results = [] - ttype, tspec = spec[0], spec[1] - r_handler = self._TTYPE_HANDLERS[ttype][0] - reader = getattr(self, r_handler) - (list_type, list_len) = self.readListBegin() - if tspec is None: - # list values are simple types - for idx in xrange(list_len): - results.append(reader()) - else: - # this is like an inlined readFieldByTType - container_reader = self._TTYPE_HANDLERS[list_type][0] - val_reader = getattr(self, container_reader) - for idx in xrange(list_len): - val = val_reader(tspec) - results.append(val) - self.readListEnd() - return results - - def readContainerSet(self, spec): - results = set() - ttype, tspec = spec[0], spec[1] - r_handler = self._TTYPE_HANDLERS[ttype][0] - reader = getattr(self, r_handler) - (set_type, set_len) = self.readSetBegin() - if tspec is None: - # set members are simple types - for idx in xrange(set_len): - results.add(reader()) - else: - container_reader = self._TTYPE_HANDLERS[set_type][0] - val_reader = getattr(self, container_reader) - for idx in xrange(set_len): - results.add(val_reader(tspec)) - self.readSetEnd() - return results - - def readContainerStruct(self, spec): - (obj_class, obj_spec) = spec - obj = obj_class() - obj.read(self) - return obj - - def readContainerMap(self, spec): - results = dict() - key_ttype, key_spec = spec[0], spec[1] - val_ttype, val_spec = spec[2], spec[3] - (map_ktype, map_vtype, map_len) = self.readMapBegin() - # TODO: compare types we just decoded with thrift_spec and abort/skip if types disagree - key_reader = getattr(self, self._TTYPE_HANDLERS[key_ttype][0]) - val_reader = getattr(self, self._TTYPE_HANDLERS[val_ttype][0]) - # list values are simple types - for idx in xrange(map_len): - if key_spec is None: - k_val = key_reader() - else: - k_val = self.readFieldByTType(key_ttype, key_spec) - if val_spec is None: - v_val = val_reader() - else: - v_val = self.readFieldByTType(val_ttype, val_spec) - # this raises a TypeError with unhashable keys types. i.e. d=dict(); d[[0,1]] = 2 fails - results[k_val] = v_val - self.readMapEnd() - return results - - def readStruct(self, obj, thrift_spec): - self.readStructBegin() - while True: - (fname, ftype, fid) = self.readFieldBegin() - if ftype == TType.STOP: - break - try: - field = thrift_spec[fid] - except IndexError: - self.skip(ftype) - else: - if field is not None and ftype == field[1]: - fname = field[2] - fspec = field[3] - val = self.readFieldByTType(ftype, fspec) - setattr(obj, fname, val) - else: - self.skip(ftype) - self.readFieldEnd() - self.readStructEnd() - - def writeContainerStruct(self, val, spec): - val.write(self) - - def writeContainerList(self, val, spec): - self.writeListBegin(spec[0], len(val)) - r_handler, w_handler, is_container = self._TTYPE_HANDLERS[spec[0]] - e_writer = getattr(self, w_handler) - if not is_container: - for elem in val: - e_writer(elem) - else: - for elem in val: - e_writer(elem, spec[1]) - self.writeListEnd() - - def writeContainerSet(self, val, spec): - self.writeSetBegin(spec[0], len(val)) - r_handler, w_handler, is_container = self._TTYPE_HANDLERS[spec[0]] - e_writer = getattr(self, w_handler) - if not is_container: - for elem in val: - e_writer(elem) - else: - for elem in val: - e_writer(elem, spec[1]) - self.writeSetEnd() - - def writeContainerMap(self, val, spec): - k_type = spec[0] - v_type = spec[2] - ignore, ktype_name, k_is_container = self._TTYPE_HANDLERS[k_type] - ignore, vtype_name, v_is_container = self._TTYPE_HANDLERS[v_type] - k_writer = getattr(self, ktype_name) - v_writer = getattr(self, vtype_name) - self.writeMapBegin(k_type, v_type, len(val)) - for m_key, m_val in val.iteritems(): - if not k_is_container: - k_writer(m_key) - else: - k_writer(m_key, spec[1]) - if not v_is_container: - v_writer(m_val) - else: - v_writer(m_val, spec[3]) - self.writeMapEnd() - - def writeStruct(self, obj, thrift_spec): - self.writeStructBegin(obj.__class__.__name__) - for field in thrift_spec: - if field is None: - continue - fname = field[2] - val = getattr(obj, fname) - if val is None: - # skip writing out unset fields - continue - fid = field[0] - ftype = field[1] - fspec = field[3] - # get the writer method for this value - self.writeFieldBegin(fname, ftype, fid) - self.writeFieldByTType(ftype, val, fspec) - self.writeFieldEnd() - self.writeFieldStop() - self.writeStructEnd() - - def writeFieldByTType(self, ttype, val, spec): - r_handler, w_handler, is_container = self._TTYPE_HANDLERS[ttype] - writer = getattr(self, w_handler) - if is_container: - writer(val, spec) - else: - writer(val) - -class TProtocolFactory: - def getProtocol(self, trans): - pass - diff --git a/anknotes/thrift/protocol/TProtocol.py~HEAD b/anknotes/thrift/protocol/TProtocol.py~HEAD deleted file mode 100644 index 7338ff6..0000000 --- a/anknotes/thrift/protocol/TProtocol.py~HEAD +++ /dev/null @@ -1,404 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from thrift.Thrift import * - -class TProtocolException(TException): - - """Custom Protocol Exception class""" - - UNKNOWN = 0 - INVALID_DATA = 1 - NEGATIVE_SIZE = 2 - SIZE_LIMIT = 3 - BAD_VERSION = 4 - - def __init__(self, type=UNKNOWN, message=None): - TException.__init__(self, message) - self.type = type - -class TProtocolBase: - - """Base class for Thrift protocol driver.""" - - def __init__(self, trans): - self.trans = trans - - def writeMessageBegin(self, name, type, seqid): - pass - - def writeMessageEnd(self): - pass - - def writeStructBegin(self, name): - pass - - def writeStructEnd(self): - pass - - def writeFieldBegin(self, name, type, id): - pass - - def writeFieldEnd(self): - pass - - def writeFieldStop(self): - pass - - def writeMapBegin(self, ktype, vtype, size): - pass - - def writeMapEnd(self): - pass - - def writeListBegin(self, etype, size): - pass - - def writeListEnd(self): - pass - - def writeSetBegin(self, etype, size): - pass - - def writeSetEnd(self): - pass - - def writeBool(self, bool): - pass - - def writeByte(self, byte): - pass - - def writeI16(self, i16): - pass - - def writeI32(self, i32): - pass - - def writeI64(self, i64): - pass - - def writeDouble(self, dub): - pass - - def writeString(self, str): - pass - - def readMessageBegin(self): - pass - - def readMessageEnd(self): - pass - - def readStructBegin(self): - pass - - def readStructEnd(self): - pass - - def readFieldBegin(self): - pass - - def readFieldEnd(self): - pass - - def readMapBegin(self): - pass - - def readMapEnd(self): - pass - - def readListBegin(self): - pass - - def readListEnd(self): - pass - - def readSetBegin(self): - pass - - def readSetEnd(self): - pass - - def readBool(self): - pass - - def readByte(self): - pass - - def readI16(self): - pass - - def readI32(self): - pass - - def readI64(self): - pass - - def readDouble(self): - pass - - def readString(self): - pass - - def skip(self, type): - if type == TType.STOP: - return - elif type == TType.BOOL: - self.readBool() - elif type == TType.BYTE: - self.readByte() - elif type == TType.I16: - self.readI16() - elif type == TType.I32: - self.readI32() - elif type == TType.I64: - self.readI64() - elif type == TType.DOUBLE: - self.readDouble() - elif type == TType.STRING: - self.readString() - elif type == TType.STRUCT: - name = self.readStructBegin() - while True: - (name, type, id) = self.readFieldBegin() - if type == TType.STOP: - break - self.skip(type) - self.readFieldEnd() - self.readStructEnd() - elif type == TType.MAP: - (ktype, vtype, size) = self.readMapBegin() - for i in range(size): - self.skip(ktype) - self.skip(vtype) - self.readMapEnd() - elif type == TType.SET: - (etype, size) = self.readSetBegin() - for i in range(size): - self.skip(etype) - self.readSetEnd() - elif type == TType.LIST: - (etype, size) = self.readListBegin() - for i in range(size): - self.skip(etype) - self.readListEnd() - - # tuple of: ( 'reader method' name, is_container boolean, 'writer_method' name ) - _TTYPE_HANDLERS = ( - (None, None, False), # 0 == TType,STOP - (None, None, False), # 1 == TType.VOID # TODO: handle void? - ('readBool', 'writeBool', False), # 2 == TType.BOOL - ('readByte', 'writeByte', False), # 3 == TType.BYTE and I08 - ('readDouble', 'writeDouble', False), # 4 == TType.DOUBLE - (None, None, False), # 5, undefined - ('readI16', 'writeI16', False), # 6 == TType.I16 - (None, None, False), # 7, undefined - ('readI32', 'writeI32', False), # 8 == TType.I32 - (None, None, False), # 9, undefined - ('readI64', 'writeI64', False), # 10 == TType.I64 - ('readString', 'writeString', False), # 11 == TType.STRING and UTF7 - ('readContainerStruct', 'writeContainerStruct', True), # 12 == TType.STRUCT - ('readContainerMap', 'writeContainerMap', True), # 13 == TType.MAP - ('readContainerSet', 'writeContainerSet', True), # 14 == TType.SET - ('readContainerList', 'writeContainerList', True), # 15 == TType.LIST - (None, None, False), # 16 == TType.UTF8 # TODO: handle utf8 types? - (None, None, False)# 17 == TType.UTF16 # TODO: handle utf16 types? - ) - - def readFieldByTType(self, ttype, spec): - try: - (r_handler, w_handler, is_container) = self._TTYPE_HANDLERS[ttype] - except IndexError: - raise TProtocolException(type=TProtocolException.INVALID_DATA, - message='Invalid field type %d' % (ttype)) - if r_handler is None: - raise TProtocolException(type=TProtocolException.INVALID_DATA, - message='Invalid field type %d' % (ttype)) - reader = getattr(self, r_handler) - if not is_container: - return reader() - return reader(spec) - - def readContainerList(self, spec): - results = [] - ttype, tspec = spec[0], spec[1] - r_handler = self._TTYPE_HANDLERS[ttype][0] - reader = getattr(self, r_handler) - (list_type, list_len) = self.readListBegin() - if tspec is None: - # list values are simple types - for idx in xrange(list_len): - results.append(reader()) - else: - # this is like an inlined readFieldByTType - container_reader = self._TTYPE_HANDLERS[list_type][0] - val_reader = getattr(self, container_reader) - for idx in xrange(list_len): - val = val_reader(tspec) - results.append(val) - self.readListEnd() - return results - - def readContainerSet(self, spec): - results = set() - ttype, tspec = spec[0], spec[1] - r_handler = self._TTYPE_HANDLERS[ttype][0] - reader = getattr(self, r_handler) - (set_type, set_len) = self.readSetBegin() - if tspec is None: - # set members are simple types - for idx in xrange(set_len): - results.add(reader()) - else: - container_reader = self._TTYPE_HANDLERS[set_type][0] - val_reader = getattr(self, container_reader) - for idx in xrange(set_len): - results.add(val_reader(tspec)) - self.readSetEnd() - return results - - def readContainerStruct(self, spec): - (obj_class, obj_spec) = spec - obj = obj_class() - obj.read(self) - return obj - - def readContainerMap(self, spec): - results = dict() - key_ttype, key_spec = spec[0], spec[1] - val_ttype, val_spec = spec[2], spec[3] - (map_ktype, map_vtype, map_len) = self.readMapBegin() - # TODO: compare types we just decoded with thrift_spec and abort/skip if types disagree - key_reader = getattr(self, self._TTYPE_HANDLERS[key_ttype][0]) - val_reader = getattr(self, self._TTYPE_HANDLERS[val_ttype][0]) - # list values are simple types - for idx in xrange(map_len): - if key_spec is None: - k_val = key_reader() - else: - k_val = self.readFieldByTType(key_ttype, key_spec) - if val_spec is None: - v_val = val_reader() - else: - v_val = self.readFieldByTType(val_ttype, val_spec) - # this raises a TypeError with unhashable keys types. i.e. d=dict(); d[[0,1]] = 2 fails - results[k_val] = v_val - self.readMapEnd() - return results - - def readStruct(self, obj, thrift_spec): - self.readStructBegin() - while True: - (fname, ftype, fid) = self.readFieldBegin() - if ftype == TType.STOP: - break - try: - field = thrift_spec[fid] - except IndexError: - self.skip(ftype) - else: - if field is not None and ftype == field[1]: - fname = field[2] - fspec = field[3] - val = self.readFieldByTType(ftype, fspec) - setattr(obj, fname, val) - else: - self.skip(ftype) - self.readFieldEnd() - self.readStructEnd() - - def writeContainerStruct(self, val, spec): - val.write(self) - - def writeContainerList(self, val, spec): - self.writeListBegin(spec[0], len(val)) - r_handler, w_handler, is_container = self._TTYPE_HANDLERS[spec[0]] - e_writer = getattr(self, w_handler) - if not is_container: - for elem in val: - e_writer(elem) - else: - for elem in val: - e_writer(elem, spec[1]) - self.writeListEnd() - - def writeContainerSet(self, val, spec): - self.writeSetBegin(spec[0], len(val)) - r_handler, w_handler, is_container = self._TTYPE_HANDLERS[spec[0]] - e_writer = getattr(self, w_handler) - if not is_container: - for elem in val: - e_writer(elem) - else: - for elem in val: - e_writer(elem, spec[1]) - self.writeSetEnd() - - def writeContainerMap(self, val, spec): - k_type = spec[0] - v_type = spec[2] - ignore, ktype_name, k_is_container = self._TTYPE_HANDLERS[k_type] - ignore, vtype_name, v_is_container = self._TTYPE_HANDLERS[v_type] - k_writer = getattr(self, ktype_name) - v_writer = getattr(self, vtype_name) - self.writeMapBegin(k_type, v_type, len(val)) - for m_key, m_val in val.iteritems(): - if not k_is_container: - k_writer(m_key) - else: - k_writer(m_key, spec[1]) - if not v_is_container: - v_writer(m_val) - else: - v_writer(m_val, spec[3]) - self.writeMapEnd() - - def writeStruct(self, obj, thrift_spec): - self.writeStructBegin(obj.__class__.__name__) - for field in thrift_spec: - if field is None: - continue - fname = field[2] - val = getattr(obj, fname) - if val is None: - # skip writing out unset fields - continue - fid = field[0] - ftype = field[1] - fspec = field[3] - # get the writer method for this value - self.writeFieldBegin(fname, ftype, fid) - self.writeFieldByTType(ftype, val, fspec) - self.writeFieldEnd() - self.writeFieldStop() - self.writeStructEnd() - - def writeFieldByTType(self, ttype, val, spec): - r_handler, w_handler, is_container = self._TTYPE_HANDLERS[ttype] - writer = getattr(self, w_handler) - if is_container: - writer(val, spec) - else: - writer(val) - -class TProtocolFactory: - def getProtocol(self, trans): - pass - diff --git a/anknotes/thrift/protocol/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/protocol/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index d53359b..0000000 --- a/anknotes/thrift/protocol/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,20 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -__all__ = ['TProtocol', 'TBinaryProtocol', 'fastbinary', 'TBase'] diff --git a/anknotes/thrift/protocol/__init__.py~HEAD b/anknotes/thrift/protocol/__init__.py~HEAD deleted file mode 100644 index d53359b..0000000 --- a/anknotes/thrift/protocol/__init__.py~HEAD +++ /dev/null @@ -1,20 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -__all__ = ['TProtocol', 'TBinaryProtocol', 'fastbinary', 'TBase'] diff --git a/anknotes/thrift/protocol/fastbinary.c~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/protocol/fastbinary.c~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 2ce5660..0000000 --- a/anknotes/thrift/protocol/fastbinary.c~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,1219 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -#include <Python.h> -#include "cStringIO.h" -#include <stdint.h> -#ifndef _WIN32 -# include <stdbool.h> -# include <netinet/in.h> -#else -# include <WinSock2.h> -# pragma comment (lib, "ws2_32.lib") -# define BIG_ENDIAN (4321) -# define LITTLE_ENDIAN (1234) -# define BYTE_ORDER LITTLE_ENDIAN -# if defined(_MSC_VER) && _MSC_VER < 1600 - typedef int _Bool; -# define bool _Bool -# define false 0 -# define true 1 -# endif -# define inline __inline -#endif - -/* Fix endianness issues on Solaris */ -#if defined (__SVR4) && defined (__sun) - #if defined(__i386) && !defined(__i386__) - #define __i386__ - #endif - - #ifndef BIG_ENDIAN - #define BIG_ENDIAN (4321) - #endif - #ifndef LITTLE_ENDIAN - #define LITTLE_ENDIAN (1234) - #endif - - /* I386 is LE, even on Solaris */ - #if !defined(BYTE_ORDER) && defined(__i386__) - #define BYTE_ORDER LITTLE_ENDIAN - #endif -#endif - -// TODO(dreiss): defval appears to be unused. Look into removing it. -// TODO(dreiss): Make parse_spec_args recursive, and cache the output -// permanently in the object. (Malloc and orphan.) -// TODO(dreiss): Why do we need cStringIO for reading, why not just char*? -// Can cStringIO let us work with a BufferedTransport? -// TODO(dreiss): Don't ignore the rv from cwrite (maybe). - -/* ====== BEGIN UTILITIES ====== */ - -#define INIT_OUTBUF_SIZE 128 - -// Stolen out of TProtocol.h. -// It would be a huge pain to have both get this from one place. -typedef enum TType { - T_STOP = 0, - T_VOID = 1, - T_BOOL = 2, - T_BYTE = 3, - T_I08 = 3, - T_I16 = 6, - T_I32 = 8, - T_U64 = 9, - T_I64 = 10, - T_DOUBLE = 4, - T_STRING = 11, - T_UTF7 = 11, - T_STRUCT = 12, - T_MAP = 13, - T_SET = 14, - T_LIST = 15, - T_UTF8 = 16, - T_UTF16 = 17 -} TType; - -#ifndef __BYTE_ORDER -# if defined(BYTE_ORDER) && defined(LITTLE_ENDIAN) && defined(BIG_ENDIAN) -# define __BYTE_ORDER BYTE_ORDER -# define __LITTLE_ENDIAN LITTLE_ENDIAN -# define __BIG_ENDIAN BIG_ENDIAN -# else -# error "Cannot determine endianness" -# endif -#endif - -// Same comment as the enum. Sorry. -#if __BYTE_ORDER == __BIG_ENDIAN -# define ntohll(n) (n) -# define htonll(n) (n) -#elif __BYTE_ORDER == __LITTLE_ENDIAN -# if defined(__GNUC__) && defined(__GLIBC__) -# include <byteswap.h> -# define ntohll(n) bswap_64(n) -# define htonll(n) bswap_64(n) -# else /* GNUC & GLIBC */ -# define ntohll(n) ( (((unsigned long long)ntohl(n)) << 32) + ntohl(n >> 32) ) -# define htonll(n) ( (((unsigned long long)htonl(n)) << 32) + htonl(n >> 32) ) -# endif /* GNUC & GLIBC */ -#else /* __BYTE_ORDER */ -# error "Can't define htonll or ntohll!" -#endif - -// Doing a benchmark shows that interning actually makes a difference, amazingly. -#define INTERN_STRING(value) _intern_ ## value - -#define INT_CONV_ERROR_OCCURRED(v) ( ((v) == -1) && PyErr_Occurred() ) -#define CHECK_RANGE(v, min, max) ( ((v) <= (max)) && ((v) >= (min)) ) - -// Py_ssize_t was not defined before Python 2.5 -#if (PY_VERSION_HEX < 0x02050000) -typedef int Py_ssize_t; -#endif - -/** - * A cache of the spec_args for a set or list, - * so we don't have to keep calling PyTuple_GET_ITEM. - */ -typedef struct { - TType element_type; - PyObject* typeargs; -} SetListTypeArgs; - -/** - * A cache of the spec_args for a map, - * so we don't have to keep calling PyTuple_GET_ITEM. - */ -typedef struct { - TType ktag; - TType vtag; - PyObject* ktypeargs; - PyObject* vtypeargs; -} MapTypeArgs; - -/** - * A cache of the spec_args for a struct, - * so we don't have to keep calling PyTuple_GET_ITEM. - */ -typedef struct { - PyObject* klass; - PyObject* spec; -} StructTypeArgs; - -/** - * A cache of the item spec from a struct specification, - * so we don't have to keep calling PyTuple_GET_ITEM. - */ -typedef struct { - int tag; - TType type; - PyObject* attrname; - PyObject* typeargs; - PyObject* defval; -} StructItemSpec; - -/** - * A cache of the two key attributes of a CReadableTransport, - * so we don't have to keep calling PyObject_GetAttr. - */ -typedef struct { - PyObject* stringiobuf; - PyObject* refill_callable; -} DecodeBuffer; - -/** Pointer to interned string to speed up attribute lookup. */ -static PyObject* INTERN_STRING(cstringio_buf); -/** Pointer to interned string to speed up attribute lookup. */ -static PyObject* INTERN_STRING(cstringio_refill); - -static inline bool -check_ssize_t_32(Py_ssize_t len) { - // error from getting the int - if (INT_CONV_ERROR_OCCURRED(len)) { - return false; - } - if (!CHECK_RANGE(len, 0, INT32_MAX)) { - PyErr_SetString(PyExc_OverflowError, "string size out of range"); - return false; - } - return true; -} - -static inline bool -parse_pyint(PyObject* o, int32_t* ret, int32_t min, int32_t max) { - long val = PyInt_AsLong(o); - - if (INT_CONV_ERROR_OCCURRED(val)) { - return false; - } - if (!CHECK_RANGE(val, min, max)) { - PyErr_SetString(PyExc_OverflowError, "int out of range"); - return false; - } - - *ret = (int32_t) val; - return true; -} - - -/* --- FUNCTIONS TO PARSE STRUCT SPECIFICATOINS --- */ - -static bool -parse_set_list_args(SetListTypeArgs* dest, PyObject* typeargs) { - if (PyTuple_Size(typeargs) != 2) { - PyErr_SetString(PyExc_TypeError, "expecting tuple of size 2 for list/set type args"); - return false; - } - - dest->element_type = PyInt_AsLong(PyTuple_GET_ITEM(typeargs, 0)); - if (INT_CONV_ERROR_OCCURRED(dest->element_type)) { - return false; - } - - dest->typeargs = PyTuple_GET_ITEM(typeargs, 1); - - return true; -} - -static bool -parse_map_args(MapTypeArgs* dest, PyObject* typeargs) { - if (PyTuple_Size(typeargs) != 4) { - PyErr_SetString(PyExc_TypeError, "expecting 4 arguments for typeargs to map"); - return false; - } - - dest->ktag = PyInt_AsLong(PyTuple_GET_ITEM(typeargs, 0)); - if (INT_CONV_ERROR_OCCURRED(dest->ktag)) { - return false; - } - - dest->vtag = PyInt_AsLong(PyTuple_GET_ITEM(typeargs, 2)); - if (INT_CONV_ERROR_OCCURRED(dest->vtag)) { - return false; - } - - dest->ktypeargs = PyTuple_GET_ITEM(typeargs, 1); - dest->vtypeargs = PyTuple_GET_ITEM(typeargs, 3); - - return true; -} - -static bool -parse_struct_args(StructTypeArgs* dest, PyObject* typeargs) { - if (PyTuple_Size(typeargs) != 2) { - PyErr_SetString(PyExc_TypeError, "expecting tuple of size 2 for struct args"); - return false; - } - - dest->klass = PyTuple_GET_ITEM(typeargs, 0); - dest->spec = PyTuple_GET_ITEM(typeargs, 1); - - return true; -} - -static int -parse_struct_item_spec(StructItemSpec* dest, PyObject* spec_tuple) { - - // i'd like to use ParseArgs here, but it seems to be a bottleneck. - if (PyTuple_Size(spec_tuple) != 5) { - PyErr_SetString(PyExc_TypeError, "expecting 5 arguments for spec tuple"); - return false; - } - - dest->tag = PyInt_AsLong(PyTuple_GET_ITEM(spec_tuple, 0)); - if (INT_CONV_ERROR_OCCURRED(dest->tag)) { - return false; - } - - dest->type = PyInt_AsLong(PyTuple_GET_ITEM(spec_tuple, 1)); - if (INT_CONV_ERROR_OCCURRED(dest->type)) { - return false; - } - - dest->attrname = PyTuple_GET_ITEM(spec_tuple, 2); - dest->typeargs = PyTuple_GET_ITEM(spec_tuple, 3); - dest->defval = PyTuple_GET_ITEM(spec_tuple, 4); - return true; -} - -/* ====== END UTILITIES ====== */ - - -/* ====== BEGIN WRITING FUNCTIONS ====== */ - -/* --- LOW-LEVEL WRITING FUNCTIONS --- */ - -static void writeByte(PyObject* outbuf, int8_t val) { - int8_t net = val; - PycStringIO->cwrite(outbuf, (char*)&net, sizeof(int8_t)); -} - -static void writeI16(PyObject* outbuf, int16_t val) { - int16_t net = (int16_t)htons(val); - PycStringIO->cwrite(outbuf, (char*)&net, sizeof(int16_t)); -} - -static void writeI32(PyObject* outbuf, int32_t val) { - int32_t net = (int32_t)htonl(val); - PycStringIO->cwrite(outbuf, (char*)&net, sizeof(int32_t)); -} - -static void writeI64(PyObject* outbuf, int64_t val) { - int64_t net = (int64_t)htonll(val); - PycStringIO->cwrite(outbuf, (char*)&net, sizeof(int64_t)); -} - -static void writeDouble(PyObject* outbuf, double dub) { - // Unfortunately, bitwise_cast doesn't work in C. Bad C! - union { - double f; - int64_t t; - } transfer; - transfer.f = dub; - writeI64(outbuf, transfer.t); -} - - -/* --- MAIN RECURSIVE OUTPUT FUCNTION -- */ - -static int -output_val(PyObject* output, PyObject* value, TType type, PyObject* typeargs) { - /* - * Refcounting Strategy: - * - * We assume that elements of the thrift_spec tuple are not going to be - * mutated, so we don't ref count those at all. Other than that, we try to - * keep a reference to all the user-created objects while we work with them. - * output_val assumes that a reference is already held. The *caller* is - * responsible for handling references - */ - - switch (type) { - - case T_BOOL: { - int v = PyObject_IsTrue(value); - if (v == -1) { - return false; - } - - writeByte(output, (int8_t) v); - break; - } - case T_I08: { - int32_t val; - - if (!parse_pyint(value, &val, INT8_MIN, INT8_MAX)) { - return false; - } - - writeByte(output, (int8_t) val); - break; - } - case T_I16: { - int32_t val; - - if (!parse_pyint(value, &val, INT16_MIN, INT16_MAX)) { - return false; - } - - writeI16(output, (int16_t) val); - break; - } - case T_I32: { - int32_t val; - - if (!parse_pyint(value, &val, INT32_MIN, INT32_MAX)) { - return false; - } - - writeI32(output, val); - break; - } - case T_I64: { - int64_t nval = PyLong_AsLongLong(value); - - if (INT_CONV_ERROR_OCCURRED(nval)) { - return false; - } - - if (!CHECK_RANGE(nval, INT64_MIN, INT64_MAX)) { - PyErr_SetString(PyExc_OverflowError, "int out of range"); - return false; - } - - writeI64(output, nval); - break; - } - - case T_DOUBLE: { - double nval = PyFloat_AsDouble(value); - if (nval == -1.0 && PyErr_Occurred()) { - return false; - } - - writeDouble(output, nval); - break; - } - - case T_STRING: { - Py_ssize_t len = PyString_Size(value); - - if (!check_ssize_t_32(len)) { - return false; - } - - writeI32(output, (int32_t) len); - PycStringIO->cwrite(output, PyString_AsString(value), (int32_t) len); - break; - } - - case T_LIST: - case T_SET: { - Py_ssize_t len; - SetListTypeArgs parsedargs; - PyObject *item; - PyObject *iterator; - - if (!parse_set_list_args(&parsedargs, typeargs)) { - return false; - } - - len = PyObject_Length(value); - - if (!check_ssize_t_32(len)) { - return false; - } - - writeByte(output, parsedargs.element_type); - writeI32(output, (int32_t) len); - - iterator = PyObject_GetIter(value); - if (iterator == NULL) { - return false; - } - - while ((item = PyIter_Next(iterator))) { - if (!output_val(output, item, parsedargs.element_type, parsedargs.typeargs)) { - Py_DECREF(item); - Py_DECREF(iterator); - return false; - } - Py_DECREF(item); - } - - Py_DECREF(iterator); - - if (PyErr_Occurred()) { - return false; - } - - break; - } - - case T_MAP: { - PyObject *k, *v; - Py_ssize_t pos = 0; - Py_ssize_t len; - - MapTypeArgs parsedargs; - - len = PyDict_Size(value); - if (!check_ssize_t_32(len)) { - return false; - } - - if (!parse_map_args(&parsedargs, typeargs)) { - return false; - } - - writeByte(output, parsedargs.ktag); - writeByte(output, parsedargs.vtag); - writeI32(output, len); - - // TODO(bmaurer): should support any mapping, not just dicts - while (PyDict_Next(value, &pos, &k, &v)) { - // TODO(dreiss): Think hard about whether these INCREFs actually - // turn any unsafe scenarios into safe scenarios. - Py_INCREF(k); - Py_INCREF(v); - - if (!output_val(output, k, parsedargs.ktag, parsedargs.ktypeargs) - || !output_val(output, v, parsedargs.vtag, parsedargs.vtypeargs)) { - Py_DECREF(k); - Py_DECREF(v); - return false; - } - Py_DECREF(k); - Py_DECREF(v); - } - break; - } - - // TODO(dreiss): Consider breaking this out as a function - // the way we did for decode_struct. - case T_STRUCT: { - StructTypeArgs parsedargs; - Py_ssize_t nspec; - Py_ssize_t i; - - if (!parse_struct_args(&parsedargs, typeargs)) { - return false; - } - - nspec = PyTuple_Size(parsedargs.spec); - - if (nspec == -1) { - return false; - } - - for (i = 0; i < nspec; i++) { - StructItemSpec parsedspec; - PyObject* spec_tuple; - PyObject* instval = NULL; - - spec_tuple = PyTuple_GET_ITEM(parsedargs.spec, i); - if (spec_tuple == Py_None) { - continue; - } - - if (!parse_struct_item_spec (&parsedspec, spec_tuple)) { - return false; - } - - instval = PyObject_GetAttr(value, parsedspec.attrname); - - if (!instval) { - return false; - } - - if (instval == Py_None) { - Py_DECREF(instval); - continue; - } - - writeByte(output, (int8_t) parsedspec.type); - writeI16(output, parsedspec.tag); - - if (!output_val(output, instval, parsedspec.type, parsedspec.typeargs)) { - Py_DECREF(instval); - return false; - } - - Py_DECREF(instval); - } - - writeByte(output, (int8_t)T_STOP); - break; - } - - case T_STOP: - case T_VOID: - case T_UTF16: - case T_UTF8: - case T_U64: - default: - PyErr_SetString(PyExc_TypeError, "Unexpected TType"); - return false; - - } - - return true; -} - - -/* --- TOP-LEVEL WRAPPER FOR OUTPUT -- */ - -static PyObject * -encode_binary(PyObject *self, PyObject *args) { - PyObject* enc_obj; - PyObject* type_args; - PyObject* buf; - PyObject* ret = NULL; - - if (!PyArg_ParseTuple(args, "OO", &enc_obj, &type_args)) { - return NULL; - } - - buf = PycStringIO->NewOutput(INIT_OUTBUF_SIZE); - if (output_val(buf, enc_obj, T_STRUCT, type_args)) { - ret = PycStringIO->cgetvalue(buf); - } - - Py_DECREF(buf); - return ret; -} - -/* ====== END WRITING FUNCTIONS ====== */ - - -/* ====== BEGIN READING FUNCTIONS ====== */ - -/* --- LOW-LEVEL READING FUNCTIONS --- */ - -static void -free_decodebuf(DecodeBuffer* d) { - Py_XDECREF(d->stringiobuf); - Py_XDECREF(d->refill_callable); -} - -static bool -decode_buffer_from_obj(DecodeBuffer* dest, PyObject* obj) { - dest->stringiobuf = PyObject_GetAttr(obj, INTERN_STRING(cstringio_buf)); - if (!dest->stringiobuf) { - return false; - } - - if (!PycStringIO_InputCheck(dest->stringiobuf)) { - free_decodebuf(dest); - PyErr_SetString(PyExc_TypeError, "expecting stringio input"); - return false; - } - - dest->refill_callable = PyObject_GetAttr(obj, INTERN_STRING(cstringio_refill)); - - if(!dest->refill_callable) { - free_decodebuf(dest); - return false; - } - - if (!PyCallable_Check(dest->refill_callable)) { - free_decodebuf(dest); - PyErr_SetString(PyExc_TypeError, "expecting callable"); - return false; - } - - return true; -} - -static bool readBytes(DecodeBuffer* input, char** output, int len) { - int read; - - // TODO(dreiss): Don't fear the malloc. Think about taking a copy of - // the partial read instead of forcing the transport - // to prepend it to its buffer. - - read = PycStringIO->cread(input->stringiobuf, output, len); - - if (read == len) { - return true; - } else if (read == -1) { - return false; - } else { - PyObject* newiobuf; - - // using building functions as this is a rare codepath - newiobuf = PyObject_CallFunction( - input->refill_callable, "s#i", *output, read, len, NULL); - if (newiobuf == NULL) { - return false; - } - - // must do this *AFTER* the call so that we don't deref the io buffer - Py_CLEAR(input->stringiobuf); - input->stringiobuf = newiobuf; - - read = PycStringIO->cread(input->stringiobuf, output, len); - - if (read == len) { - return true; - } else if (read == -1) { - return false; - } else { - // TODO(dreiss): This could be a valid code path for big binary blobs. - PyErr_SetString(PyExc_TypeError, - "refill claimed to have refilled the buffer, but didn't!!"); - return false; - } - } -} - -static int8_t readByte(DecodeBuffer* input) { - char* buf; - if (!readBytes(input, &buf, sizeof(int8_t))) { - return -1; - } - - return *(int8_t*) buf; -} - -static int16_t readI16(DecodeBuffer* input) { - char* buf; - if (!readBytes(input, &buf, sizeof(int16_t))) { - return -1; - } - - return (int16_t) ntohs(*(int16_t*) buf); -} - -static int32_t readI32(DecodeBuffer* input) { - char* buf; - if (!readBytes(input, &buf, sizeof(int32_t))) { - return -1; - } - return (int32_t) ntohl(*(int32_t*) buf); -} - - -static int64_t readI64(DecodeBuffer* input) { - char* buf; - if (!readBytes(input, &buf, sizeof(int64_t))) { - return -1; - } - - return (int64_t) ntohll(*(int64_t*) buf); -} - -static double readDouble(DecodeBuffer* input) { - union { - int64_t f; - double t; - } transfer; - - transfer.f = readI64(input); - if (transfer.f == -1) { - return -1; - } - return transfer.t; -} - -static bool -checkTypeByte(DecodeBuffer* input, TType expected) { - TType got = readByte(input); - if (INT_CONV_ERROR_OCCURRED(got)) { - return false; - } - - if (expected != got) { - PyErr_SetString(PyExc_TypeError, "got wrong ttype while reading field"); - return false; - } - return true; -} - -static bool -skip(DecodeBuffer* input, TType type) { -#define SKIPBYTES(n) \ - do { \ - if (!readBytes(input, &dummy_buf, (n))) { \ - return false; \ - } \ - } while(0) - - char* dummy_buf; - - switch (type) { - - case T_BOOL: - case T_I08: SKIPBYTES(1); break; - case T_I16: SKIPBYTES(2); break; - case T_I32: SKIPBYTES(4); break; - case T_I64: - case T_DOUBLE: SKIPBYTES(8); break; - - case T_STRING: { - // TODO(dreiss): Find out if these check_ssize_t32s are really necessary. - int len = readI32(input); - if (!check_ssize_t_32(len)) { - return false; - } - SKIPBYTES(len); - break; - } - - case T_LIST: - case T_SET: { - TType etype; - int len, i; - - etype = readByte(input); - if (etype == -1) { - return false; - } - - len = readI32(input); - if (!check_ssize_t_32(len)) { - return false; - } - - for (i = 0; i < len; i++) { - if (!skip(input, etype)) { - return false; - } - } - break; - } - - case T_MAP: { - TType ktype, vtype; - int len, i; - - ktype = readByte(input); - if (ktype == -1) { - return false; - } - - vtype = readByte(input); - if (vtype == -1) { - return false; - } - - len = readI32(input); - if (!check_ssize_t_32(len)) { - return false; - } - - for (i = 0; i < len; i++) { - if (!(skip(input, ktype) && skip(input, vtype))) { - return false; - } - } - break; - } - - case T_STRUCT: { - while (true) { - TType type; - - type = readByte(input); - if (type == -1) { - return false; - } - - if (type == T_STOP) - break; - - SKIPBYTES(2); // tag - if (!skip(input, type)) { - return false; - } - } - break; - } - - case T_STOP: - case T_VOID: - case T_UTF16: - case T_UTF8: - case T_U64: - default: - PyErr_SetString(PyExc_TypeError, "Unexpected TType"); - return false; - - } - - return true; - -#undef SKIPBYTES -} - - -/* --- HELPER FUNCTION FOR DECODE_VAL --- */ - -static PyObject* -decode_val(DecodeBuffer* input, TType type, PyObject* typeargs); - -static bool -decode_struct(DecodeBuffer* input, PyObject* output, PyObject* spec_seq) { - int spec_seq_len = PyTuple_Size(spec_seq); - if (spec_seq_len == -1) { - return false; - } - - while (true) { - TType type; - int16_t tag; - PyObject* item_spec; - PyObject* fieldval = NULL; - StructItemSpec parsedspec; - - type = readByte(input); - if (type == -1) { - return false; - } - if (type == T_STOP) { - break; - } - tag = readI16(input); - if (INT_CONV_ERROR_OCCURRED(tag)) { - return false; - } - if (tag >= 0 && tag < spec_seq_len) { - item_spec = PyTuple_GET_ITEM(spec_seq, tag); - } else { - item_spec = Py_None; - } - - if (item_spec == Py_None) { - if (!skip(input, type)) { - return false; - } else { - continue; - } - } - - if (!parse_struct_item_spec(&parsedspec, item_spec)) { - return false; - } - if (parsedspec.type != type) { - if (!skip(input, type)) { - PyErr_SetString(PyExc_TypeError, "struct field had wrong type while reading and can't be skipped"); - return false; - } else { - continue; - } - } - - fieldval = decode_val(input, parsedspec.type, parsedspec.typeargs); - if (fieldval == NULL) { - return false; - } - - if (PyObject_SetAttr(output, parsedspec.attrname, fieldval) == -1) { - Py_DECREF(fieldval); - return false; - } - Py_DECREF(fieldval); - } - return true; -} - - -/* --- MAIN RECURSIVE INPUT FUCNTION --- */ - -// Returns a new reference. -static PyObject* -decode_val(DecodeBuffer* input, TType type, PyObject* typeargs) { - switch (type) { - - case T_BOOL: { - int8_t v = readByte(input); - if (INT_CONV_ERROR_OCCURRED(v)) { - return NULL; - } - - switch (v) { - case 0: Py_RETURN_FALSE; - case 1: Py_RETURN_TRUE; - // Don't laugh. This is a potentially serious issue. - default: PyErr_SetString(PyExc_TypeError, "boolean out of range"); return NULL; - } - break; - } - case T_I08: { - int8_t v = readByte(input); - if (INT_CONV_ERROR_OCCURRED(v)) { - return NULL; - } - - return PyInt_FromLong(v); - } - case T_I16: { - int16_t v = readI16(input); - if (INT_CONV_ERROR_OCCURRED(v)) { - return NULL; - } - return PyInt_FromLong(v); - } - case T_I32: { - int32_t v = readI32(input); - if (INT_CONV_ERROR_OCCURRED(v)) { - return NULL; - } - return PyInt_FromLong(v); - } - - case T_I64: { - int64_t v = readI64(input); - if (INT_CONV_ERROR_OCCURRED(v)) { - return NULL; - } - // TODO(dreiss): Find out if we can take this fastpath always when - // sizeof(long) == sizeof(long long). - if (CHECK_RANGE(v, LONG_MIN, LONG_MAX)) { - return PyInt_FromLong((long) v); - } - - return PyLong_FromLongLong(v); - } - - case T_DOUBLE: { - double v = readDouble(input); - if (v == -1.0 && PyErr_Occurred()) { - return false; - } - return PyFloat_FromDouble(v); - } - - case T_STRING: { - Py_ssize_t len = readI32(input); - char* buf; - if (!readBytes(input, &buf, len)) { - return NULL; - } - - return PyString_FromStringAndSize(buf, len); - } - - case T_LIST: - case T_SET: { - SetListTypeArgs parsedargs; - int32_t len; - PyObject* ret = NULL; - int i; - - if (!parse_set_list_args(&parsedargs, typeargs)) { - return NULL; - } - - if (!checkTypeByte(input, parsedargs.element_type)) { - return NULL; - } - - len = readI32(input); - if (!check_ssize_t_32(len)) { - return NULL; - } - - ret = PyList_New(len); - if (!ret) { - return NULL; - } - - for (i = 0; i < len; i++) { - PyObject* item = decode_val(input, parsedargs.element_type, parsedargs.typeargs); - if (!item) { - Py_DECREF(ret); - return NULL; - } - PyList_SET_ITEM(ret, i, item); - } - - // TODO(dreiss): Consider biting the bullet and making two separate cases - // for list and set, avoiding this post facto conversion. - if (type == T_SET) { - PyObject* setret; -#if (PY_VERSION_HEX < 0x02050000) - // hack needed for older versions - setret = PyObject_CallFunctionObjArgs((PyObject*)&PySet_Type, ret, NULL); -#else - // official version - setret = PySet_New(ret); -#endif - Py_DECREF(ret); - return setret; - } - return ret; - } - - case T_MAP: { - int32_t len; - int i; - MapTypeArgs parsedargs; - PyObject* ret = NULL; - - if (!parse_map_args(&parsedargs, typeargs)) { - return NULL; - } - - if (!checkTypeByte(input, parsedargs.ktag)) { - return NULL; - } - if (!checkTypeByte(input, parsedargs.vtag)) { - return NULL; - } - - len = readI32(input); - if (!check_ssize_t_32(len)) { - return false; - } - - ret = PyDict_New(); - if (!ret) { - goto error; - } - - for (i = 0; i < len; i++) { - PyObject* k = NULL; - PyObject* v = NULL; - k = decode_val(input, parsedargs.ktag, parsedargs.ktypeargs); - if (k == NULL) { - goto loop_error; - } - v = decode_val(input, parsedargs.vtag, parsedargs.vtypeargs); - if (v == NULL) { - goto loop_error; - } - if (PyDict_SetItem(ret, k, v) == -1) { - goto loop_error; - } - - Py_DECREF(k); - Py_DECREF(v); - continue; - - // Yuck! Destructors, anyone? - loop_error: - Py_XDECREF(k); - Py_XDECREF(v); - goto error; - } - - return ret; - - error: - Py_XDECREF(ret); - return NULL; - } - - case T_STRUCT: { - StructTypeArgs parsedargs; - PyObject* ret; - if (!parse_struct_args(&parsedargs, typeargs)) { - return NULL; - } - - ret = PyObject_CallObject(parsedargs.klass, NULL); - if (!ret) { - return NULL; - } - - if (!decode_struct(input, ret, parsedargs.spec)) { - Py_DECREF(ret); - return NULL; - } - - return ret; - } - - case T_STOP: - case T_VOID: - case T_UTF16: - case T_UTF8: - case T_U64: - default: - PyErr_SetString(PyExc_TypeError, "Unexpected TType"); - return NULL; - } -} - - -/* --- TOP-LEVEL WRAPPER FOR INPUT -- */ - -static PyObject* -decode_binary(PyObject *self, PyObject *args) { - PyObject* output_obj = NULL; - PyObject* transport = NULL; - PyObject* typeargs = NULL; - StructTypeArgs parsedargs; - DecodeBuffer input = {0, 0}; - - if (!PyArg_ParseTuple(args, "OOO", &output_obj, &transport, &typeargs)) { - return NULL; - } - - if (!parse_struct_args(&parsedargs, typeargs)) { - return NULL; - } - - if (!decode_buffer_from_obj(&input, transport)) { - return NULL; - } - - if (!decode_struct(&input, output_obj, parsedargs.spec)) { - free_decodebuf(&input); - return NULL; - } - - free_decodebuf(&input); - - Py_RETURN_NONE; -} - -/* ====== END READING FUNCTIONS ====== */ - - -/* -- PYTHON MODULE SETUP STUFF --- */ - -static PyMethodDef ThriftFastBinaryMethods[] = { - - {"encode_binary", encode_binary, METH_VARARGS, ""}, - {"decode_binary", decode_binary, METH_VARARGS, ""}, - - {NULL, NULL, 0, NULL} /* Sentinel */ -}; - -PyMODINIT_FUNC -initfastbinary(void) { -#define INIT_INTERN_STRING(value) \ - do { \ - INTERN_STRING(value) = PyString_InternFromString(#value); \ - if(!INTERN_STRING(value)) return; \ - } while(0) - - INIT_INTERN_STRING(cstringio_buf); - INIT_INTERN_STRING(cstringio_refill); -#undef INIT_INTERN_STRING - - PycString_IMPORT; - if (PycStringIO == NULL) return; - - (void) Py_InitModule("thrift.protocol.fastbinary", ThriftFastBinaryMethods); -} diff --git a/anknotes/thrift/protocol/fastbinary.c~HEAD b/anknotes/thrift/protocol/fastbinary.c~HEAD deleted file mode 100644 index 2ce5660..0000000 --- a/anknotes/thrift/protocol/fastbinary.c~HEAD +++ /dev/null @@ -1,1219 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -#include <Python.h> -#include "cStringIO.h" -#include <stdint.h> -#ifndef _WIN32 -# include <stdbool.h> -# include <netinet/in.h> -#else -# include <WinSock2.h> -# pragma comment (lib, "ws2_32.lib") -# define BIG_ENDIAN (4321) -# define LITTLE_ENDIAN (1234) -# define BYTE_ORDER LITTLE_ENDIAN -# if defined(_MSC_VER) && _MSC_VER < 1600 - typedef int _Bool; -# define bool _Bool -# define false 0 -# define true 1 -# endif -# define inline __inline -#endif - -/* Fix endianness issues on Solaris */ -#if defined (__SVR4) && defined (__sun) - #if defined(__i386) && !defined(__i386__) - #define __i386__ - #endif - - #ifndef BIG_ENDIAN - #define BIG_ENDIAN (4321) - #endif - #ifndef LITTLE_ENDIAN - #define LITTLE_ENDIAN (1234) - #endif - - /* I386 is LE, even on Solaris */ - #if !defined(BYTE_ORDER) && defined(__i386__) - #define BYTE_ORDER LITTLE_ENDIAN - #endif -#endif - -// TODO(dreiss): defval appears to be unused. Look into removing it. -// TODO(dreiss): Make parse_spec_args recursive, and cache the output -// permanently in the object. (Malloc and orphan.) -// TODO(dreiss): Why do we need cStringIO for reading, why not just char*? -// Can cStringIO let us work with a BufferedTransport? -// TODO(dreiss): Don't ignore the rv from cwrite (maybe). - -/* ====== BEGIN UTILITIES ====== */ - -#define INIT_OUTBUF_SIZE 128 - -// Stolen out of TProtocol.h. -// It would be a huge pain to have both get this from one place. -typedef enum TType { - T_STOP = 0, - T_VOID = 1, - T_BOOL = 2, - T_BYTE = 3, - T_I08 = 3, - T_I16 = 6, - T_I32 = 8, - T_U64 = 9, - T_I64 = 10, - T_DOUBLE = 4, - T_STRING = 11, - T_UTF7 = 11, - T_STRUCT = 12, - T_MAP = 13, - T_SET = 14, - T_LIST = 15, - T_UTF8 = 16, - T_UTF16 = 17 -} TType; - -#ifndef __BYTE_ORDER -# if defined(BYTE_ORDER) && defined(LITTLE_ENDIAN) && defined(BIG_ENDIAN) -# define __BYTE_ORDER BYTE_ORDER -# define __LITTLE_ENDIAN LITTLE_ENDIAN -# define __BIG_ENDIAN BIG_ENDIAN -# else -# error "Cannot determine endianness" -# endif -#endif - -// Same comment as the enum. Sorry. -#if __BYTE_ORDER == __BIG_ENDIAN -# define ntohll(n) (n) -# define htonll(n) (n) -#elif __BYTE_ORDER == __LITTLE_ENDIAN -# if defined(__GNUC__) && defined(__GLIBC__) -# include <byteswap.h> -# define ntohll(n) bswap_64(n) -# define htonll(n) bswap_64(n) -# else /* GNUC & GLIBC */ -# define ntohll(n) ( (((unsigned long long)ntohl(n)) << 32) + ntohl(n >> 32) ) -# define htonll(n) ( (((unsigned long long)htonl(n)) << 32) + htonl(n >> 32) ) -# endif /* GNUC & GLIBC */ -#else /* __BYTE_ORDER */ -# error "Can't define htonll or ntohll!" -#endif - -// Doing a benchmark shows that interning actually makes a difference, amazingly. -#define INTERN_STRING(value) _intern_ ## value - -#define INT_CONV_ERROR_OCCURRED(v) ( ((v) == -1) && PyErr_Occurred() ) -#define CHECK_RANGE(v, min, max) ( ((v) <= (max)) && ((v) >= (min)) ) - -// Py_ssize_t was not defined before Python 2.5 -#if (PY_VERSION_HEX < 0x02050000) -typedef int Py_ssize_t; -#endif - -/** - * A cache of the spec_args for a set or list, - * so we don't have to keep calling PyTuple_GET_ITEM. - */ -typedef struct { - TType element_type; - PyObject* typeargs; -} SetListTypeArgs; - -/** - * A cache of the spec_args for a map, - * so we don't have to keep calling PyTuple_GET_ITEM. - */ -typedef struct { - TType ktag; - TType vtag; - PyObject* ktypeargs; - PyObject* vtypeargs; -} MapTypeArgs; - -/** - * A cache of the spec_args for a struct, - * so we don't have to keep calling PyTuple_GET_ITEM. - */ -typedef struct { - PyObject* klass; - PyObject* spec; -} StructTypeArgs; - -/** - * A cache of the item spec from a struct specification, - * so we don't have to keep calling PyTuple_GET_ITEM. - */ -typedef struct { - int tag; - TType type; - PyObject* attrname; - PyObject* typeargs; - PyObject* defval; -} StructItemSpec; - -/** - * A cache of the two key attributes of a CReadableTransport, - * so we don't have to keep calling PyObject_GetAttr. - */ -typedef struct { - PyObject* stringiobuf; - PyObject* refill_callable; -} DecodeBuffer; - -/** Pointer to interned string to speed up attribute lookup. */ -static PyObject* INTERN_STRING(cstringio_buf); -/** Pointer to interned string to speed up attribute lookup. */ -static PyObject* INTERN_STRING(cstringio_refill); - -static inline bool -check_ssize_t_32(Py_ssize_t len) { - // error from getting the int - if (INT_CONV_ERROR_OCCURRED(len)) { - return false; - } - if (!CHECK_RANGE(len, 0, INT32_MAX)) { - PyErr_SetString(PyExc_OverflowError, "string size out of range"); - return false; - } - return true; -} - -static inline bool -parse_pyint(PyObject* o, int32_t* ret, int32_t min, int32_t max) { - long val = PyInt_AsLong(o); - - if (INT_CONV_ERROR_OCCURRED(val)) { - return false; - } - if (!CHECK_RANGE(val, min, max)) { - PyErr_SetString(PyExc_OverflowError, "int out of range"); - return false; - } - - *ret = (int32_t) val; - return true; -} - - -/* --- FUNCTIONS TO PARSE STRUCT SPECIFICATOINS --- */ - -static bool -parse_set_list_args(SetListTypeArgs* dest, PyObject* typeargs) { - if (PyTuple_Size(typeargs) != 2) { - PyErr_SetString(PyExc_TypeError, "expecting tuple of size 2 for list/set type args"); - return false; - } - - dest->element_type = PyInt_AsLong(PyTuple_GET_ITEM(typeargs, 0)); - if (INT_CONV_ERROR_OCCURRED(dest->element_type)) { - return false; - } - - dest->typeargs = PyTuple_GET_ITEM(typeargs, 1); - - return true; -} - -static bool -parse_map_args(MapTypeArgs* dest, PyObject* typeargs) { - if (PyTuple_Size(typeargs) != 4) { - PyErr_SetString(PyExc_TypeError, "expecting 4 arguments for typeargs to map"); - return false; - } - - dest->ktag = PyInt_AsLong(PyTuple_GET_ITEM(typeargs, 0)); - if (INT_CONV_ERROR_OCCURRED(dest->ktag)) { - return false; - } - - dest->vtag = PyInt_AsLong(PyTuple_GET_ITEM(typeargs, 2)); - if (INT_CONV_ERROR_OCCURRED(dest->vtag)) { - return false; - } - - dest->ktypeargs = PyTuple_GET_ITEM(typeargs, 1); - dest->vtypeargs = PyTuple_GET_ITEM(typeargs, 3); - - return true; -} - -static bool -parse_struct_args(StructTypeArgs* dest, PyObject* typeargs) { - if (PyTuple_Size(typeargs) != 2) { - PyErr_SetString(PyExc_TypeError, "expecting tuple of size 2 for struct args"); - return false; - } - - dest->klass = PyTuple_GET_ITEM(typeargs, 0); - dest->spec = PyTuple_GET_ITEM(typeargs, 1); - - return true; -} - -static int -parse_struct_item_spec(StructItemSpec* dest, PyObject* spec_tuple) { - - // i'd like to use ParseArgs here, but it seems to be a bottleneck. - if (PyTuple_Size(spec_tuple) != 5) { - PyErr_SetString(PyExc_TypeError, "expecting 5 arguments for spec tuple"); - return false; - } - - dest->tag = PyInt_AsLong(PyTuple_GET_ITEM(spec_tuple, 0)); - if (INT_CONV_ERROR_OCCURRED(dest->tag)) { - return false; - } - - dest->type = PyInt_AsLong(PyTuple_GET_ITEM(spec_tuple, 1)); - if (INT_CONV_ERROR_OCCURRED(dest->type)) { - return false; - } - - dest->attrname = PyTuple_GET_ITEM(spec_tuple, 2); - dest->typeargs = PyTuple_GET_ITEM(spec_tuple, 3); - dest->defval = PyTuple_GET_ITEM(spec_tuple, 4); - return true; -} - -/* ====== END UTILITIES ====== */ - - -/* ====== BEGIN WRITING FUNCTIONS ====== */ - -/* --- LOW-LEVEL WRITING FUNCTIONS --- */ - -static void writeByte(PyObject* outbuf, int8_t val) { - int8_t net = val; - PycStringIO->cwrite(outbuf, (char*)&net, sizeof(int8_t)); -} - -static void writeI16(PyObject* outbuf, int16_t val) { - int16_t net = (int16_t)htons(val); - PycStringIO->cwrite(outbuf, (char*)&net, sizeof(int16_t)); -} - -static void writeI32(PyObject* outbuf, int32_t val) { - int32_t net = (int32_t)htonl(val); - PycStringIO->cwrite(outbuf, (char*)&net, sizeof(int32_t)); -} - -static void writeI64(PyObject* outbuf, int64_t val) { - int64_t net = (int64_t)htonll(val); - PycStringIO->cwrite(outbuf, (char*)&net, sizeof(int64_t)); -} - -static void writeDouble(PyObject* outbuf, double dub) { - // Unfortunately, bitwise_cast doesn't work in C. Bad C! - union { - double f; - int64_t t; - } transfer; - transfer.f = dub; - writeI64(outbuf, transfer.t); -} - - -/* --- MAIN RECURSIVE OUTPUT FUCNTION -- */ - -static int -output_val(PyObject* output, PyObject* value, TType type, PyObject* typeargs) { - /* - * Refcounting Strategy: - * - * We assume that elements of the thrift_spec tuple are not going to be - * mutated, so we don't ref count those at all. Other than that, we try to - * keep a reference to all the user-created objects while we work with them. - * output_val assumes that a reference is already held. The *caller* is - * responsible for handling references - */ - - switch (type) { - - case T_BOOL: { - int v = PyObject_IsTrue(value); - if (v == -1) { - return false; - } - - writeByte(output, (int8_t) v); - break; - } - case T_I08: { - int32_t val; - - if (!parse_pyint(value, &val, INT8_MIN, INT8_MAX)) { - return false; - } - - writeByte(output, (int8_t) val); - break; - } - case T_I16: { - int32_t val; - - if (!parse_pyint(value, &val, INT16_MIN, INT16_MAX)) { - return false; - } - - writeI16(output, (int16_t) val); - break; - } - case T_I32: { - int32_t val; - - if (!parse_pyint(value, &val, INT32_MIN, INT32_MAX)) { - return false; - } - - writeI32(output, val); - break; - } - case T_I64: { - int64_t nval = PyLong_AsLongLong(value); - - if (INT_CONV_ERROR_OCCURRED(nval)) { - return false; - } - - if (!CHECK_RANGE(nval, INT64_MIN, INT64_MAX)) { - PyErr_SetString(PyExc_OverflowError, "int out of range"); - return false; - } - - writeI64(output, nval); - break; - } - - case T_DOUBLE: { - double nval = PyFloat_AsDouble(value); - if (nval == -1.0 && PyErr_Occurred()) { - return false; - } - - writeDouble(output, nval); - break; - } - - case T_STRING: { - Py_ssize_t len = PyString_Size(value); - - if (!check_ssize_t_32(len)) { - return false; - } - - writeI32(output, (int32_t) len); - PycStringIO->cwrite(output, PyString_AsString(value), (int32_t) len); - break; - } - - case T_LIST: - case T_SET: { - Py_ssize_t len; - SetListTypeArgs parsedargs; - PyObject *item; - PyObject *iterator; - - if (!parse_set_list_args(&parsedargs, typeargs)) { - return false; - } - - len = PyObject_Length(value); - - if (!check_ssize_t_32(len)) { - return false; - } - - writeByte(output, parsedargs.element_type); - writeI32(output, (int32_t) len); - - iterator = PyObject_GetIter(value); - if (iterator == NULL) { - return false; - } - - while ((item = PyIter_Next(iterator))) { - if (!output_val(output, item, parsedargs.element_type, parsedargs.typeargs)) { - Py_DECREF(item); - Py_DECREF(iterator); - return false; - } - Py_DECREF(item); - } - - Py_DECREF(iterator); - - if (PyErr_Occurred()) { - return false; - } - - break; - } - - case T_MAP: { - PyObject *k, *v; - Py_ssize_t pos = 0; - Py_ssize_t len; - - MapTypeArgs parsedargs; - - len = PyDict_Size(value); - if (!check_ssize_t_32(len)) { - return false; - } - - if (!parse_map_args(&parsedargs, typeargs)) { - return false; - } - - writeByte(output, parsedargs.ktag); - writeByte(output, parsedargs.vtag); - writeI32(output, len); - - // TODO(bmaurer): should support any mapping, not just dicts - while (PyDict_Next(value, &pos, &k, &v)) { - // TODO(dreiss): Think hard about whether these INCREFs actually - // turn any unsafe scenarios into safe scenarios. - Py_INCREF(k); - Py_INCREF(v); - - if (!output_val(output, k, parsedargs.ktag, parsedargs.ktypeargs) - || !output_val(output, v, parsedargs.vtag, parsedargs.vtypeargs)) { - Py_DECREF(k); - Py_DECREF(v); - return false; - } - Py_DECREF(k); - Py_DECREF(v); - } - break; - } - - // TODO(dreiss): Consider breaking this out as a function - // the way we did for decode_struct. - case T_STRUCT: { - StructTypeArgs parsedargs; - Py_ssize_t nspec; - Py_ssize_t i; - - if (!parse_struct_args(&parsedargs, typeargs)) { - return false; - } - - nspec = PyTuple_Size(parsedargs.spec); - - if (nspec == -1) { - return false; - } - - for (i = 0; i < nspec; i++) { - StructItemSpec parsedspec; - PyObject* spec_tuple; - PyObject* instval = NULL; - - spec_tuple = PyTuple_GET_ITEM(parsedargs.spec, i); - if (spec_tuple == Py_None) { - continue; - } - - if (!parse_struct_item_spec (&parsedspec, spec_tuple)) { - return false; - } - - instval = PyObject_GetAttr(value, parsedspec.attrname); - - if (!instval) { - return false; - } - - if (instval == Py_None) { - Py_DECREF(instval); - continue; - } - - writeByte(output, (int8_t) parsedspec.type); - writeI16(output, parsedspec.tag); - - if (!output_val(output, instval, parsedspec.type, parsedspec.typeargs)) { - Py_DECREF(instval); - return false; - } - - Py_DECREF(instval); - } - - writeByte(output, (int8_t)T_STOP); - break; - } - - case T_STOP: - case T_VOID: - case T_UTF16: - case T_UTF8: - case T_U64: - default: - PyErr_SetString(PyExc_TypeError, "Unexpected TType"); - return false; - - } - - return true; -} - - -/* --- TOP-LEVEL WRAPPER FOR OUTPUT -- */ - -static PyObject * -encode_binary(PyObject *self, PyObject *args) { - PyObject* enc_obj; - PyObject* type_args; - PyObject* buf; - PyObject* ret = NULL; - - if (!PyArg_ParseTuple(args, "OO", &enc_obj, &type_args)) { - return NULL; - } - - buf = PycStringIO->NewOutput(INIT_OUTBUF_SIZE); - if (output_val(buf, enc_obj, T_STRUCT, type_args)) { - ret = PycStringIO->cgetvalue(buf); - } - - Py_DECREF(buf); - return ret; -} - -/* ====== END WRITING FUNCTIONS ====== */ - - -/* ====== BEGIN READING FUNCTIONS ====== */ - -/* --- LOW-LEVEL READING FUNCTIONS --- */ - -static void -free_decodebuf(DecodeBuffer* d) { - Py_XDECREF(d->stringiobuf); - Py_XDECREF(d->refill_callable); -} - -static bool -decode_buffer_from_obj(DecodeBuffer* dest, PyObject* obj) { - dest->stringiobuf = PyObject_GetAttr(obj, INTERN_STRING(cstringio_buf)); - if (!dest->stringiobuf) { - return false; - } - - if (!PycStringIO_InputCheck(dest->stringiobuf)) { - free_decodebuf(dest); - PyErr_SetString(PyExc_TypeError, "expecting stringio input"); - return false; - } - - dest->refill_callable = PyObject_GetAttr(obj, INTERN_STRING(cstringio_refill)); - - if(!dest->refill_callable) { - free_decodebuf(dest); - return false; - } - - if (!PyCallable_Check(dest->refill_callable)) { - free_decodebuf(dest); - PyErr_SetString(PyExc_TypeError, "expecting callable"); - return false; - } - - return true; -} - -static bool readBytes(DecodeBuffer* input, char** output, int len) { - int read; - - // TODO(dreiss): Don't fear the malloc. Think about taking a copy of - // the partial read instead of forcing the transport - // to prepend it to its buffer. - - read = PycStringIO->cread(input->stringiobuf, output, len); - - if (read == len) { - return true; - } else if (read == -1) { - return false; - } else { - PyObject* newiobuf; - - // using building functions as this is a rare codepath - newiobuf = PyObject_CallFunction( - input->refill_callable, "s#i", *output, read, len, NULL); - if (newiobuf == NULL) { - return false; - } - - // must do this *AFTER* the call so that we don't deref the io buffer - Py_CLEAR(input->stringiobuf); - input->stringiobuf = newiobuf; - - read = PycStringIO->cread(input->stringiobuf, output, len); - - if (read == len) { - return true; - } else if (read == -1) { - return false; - } else { - // TODO(dreiss): This could be a valid code path for big binary blobs. - PyErr_SetString(PyExc_TypeError, - "refill claimed to have refilled the buffer, but didn't!!"); - return false; - } - } -} - -static int8_t readByte(DecodeBuffer* input) { - char* buf; - if (!readBytes(input, &buf, sizeof(int8_t))) { - return -1; - } - - return *(int8_t*) buf; -} - -static int16_t readI16(DecodeBuffer* input) { - char* buf; - if (!readBytes(input, &buf, sizeof(int16_t))) { - return -1; - } - - return (int16_t) ntohs(*(int16_t*) buf); -} - -static int32_t readI32(DecodeBuffer* input) { - char* buf; - if (!readBytes(input, &buf, sizeof(int32_t))) { - return -1; - } - return (int32_t) ntohl(*(int32_t*) buf); -} - - -static int64_t readI64(DecodeBuffer* input) { - char* buf; - if (!readBytes(input, &buf, sizeof(int64_t))) { - return -1; - } - - return (int64_t) ntohll(*(int64_t*) buf); -} - -static double readDouble(DecodeBuffer* input) { - union { - int64_t f; - double t; - } transfer; - - transfer.f = readI64(input); - if (transfer.f == -1) { - return -1; - } - return transfer.t; -} - -static bool -checkTypeByte(DecodeBuffer* input, TType expected) { - TType got = readByte(input); - if (INT_CONV_ERROR_OCCURRED(got)) { - return false; - } - - if (expected != got) { - PyErr_SetString(PyExc_TypeError, "got wrong ttype while reading field"); - return false; - } - return true; -} - -static bool -skip(DecodeBuffer* input, TType type) { -#define SKIPBYTES(n) \ - do { \ - if (!readBytes(input, &dummy_buf, (n))) { \ - return false; \ - } \ - } while(0) - - char* dummy_buf; - - switch (type) { - - case T_BOOL: - case T_I08: SKIPBYTES(1); break; - case T_I16: SKIPBYTES(2); break; - case T_I32: SKIPBYTES(4); break; - case T_I64: - case T_DOUBLE: SKIPBYTES(8); break; - - case T_STRING: { - // TODO(dreiss): Find out if these check_ssize_t32s are really necessary. - int len = readI32(input); - if (!check_ssize_t_32(len)) { - return false; - } - SKIPBYTES(len); - break; - } - - case T_LIST: - case T_SET: { - TType etype; - int len, i; - - etype = readByte(input); - if (etype == -1) { - return false; - } - - len = readI32(input); - if (!check_ssize_t_32(len)) { - return false; - } - - for (i = 0; i < len; i++) { - if (!skip(input, etype)) { - return false; - } - } - break; - } - - case T_MAP: { - TType ktype, vtype; - int len, i; - - ktype = readByte(input); - if (ktype == -1) { - return false; - } - - vtype = readByte(input); - if (vtype == -1) { - return false; - } - - len = readI32(input); - if (!check_ssize_t_32(len)) { - return false; - } - - for (i = 0; i < len; i++) { - if (!(skip(input, ktype) && skip(input, vtype))) { - return false; - } - } - break; - } - - case T_STRUCT: { - while (true) { - TType type; - - type = readByte(input); - if (type == -1) { - return false; - } - - if (type == T_STOP) - break; - - SKIPBYTES(2); // tag - if (!skip(input, type)) { - return false; - } - } - break; - } - - case T_STOP: - case T_VOID: - case T_UTF16: - case T_UTF8: - case T_U64: - default: - PyErr_SetString(PyExc_TypeError, "Unexpected TType"); - return false; - - } - - return true; - -#undef SKIPBYTES -} - - -/* --- HELPER FUNCTION FOR DECODE_VAL --- */ - -static PyObject* -decode_val(DecodeBuffer* input, TType type, PyObject* typeargs); - -static bool -decode_struct(DecodeBuffer* input, PyObject* output, PyObject* spec_seq) { - int spec_seq_len = PyTuple_Size(spec_seq); - if (spec_seq_len == -1) { - return false; - } - - while (true) { - TType type; - int16_t tag; - PyObject* item_spec; - PyObject* fieldval = NULL; - StructItemSpec parsedspec; - - type = readByte(input); - if (type == -1) { - return false; - } - if (type == T_STOP) { - break; - } - tag = readI16(input); - if (INT_CONV_ERROR_OCCURRED(tag)) { - return false; - } - if (tag >= 0 && tag < spec_seq_len) { - item_spec = PyTuple_GET_ITEM(spec_seq, tag); - } else { - item_spec = Py_None; - } - - if (item_spec == Py_None) { - if (!skip(input, type)) { - return false; - } else { - continue; - } - } - - if (!parse_struct_item_spec(&parsedspec, item_spec)) { - return false; - } - if (parsedspec.type != type) { - if (!skip(input, type)) { - PyErr_SetString(PyExc_TypeError, "struct field had wrong type while reading and can't be skipped"); - return false; - } else { - continue; - } - } - - fieldval = decode_val(input, parsedspec.type, parsedspec.typeargs); - if (fieldval == NULL) { - return false; - } - - if (PyObject_SetAttr(output, parsedspec.attrname, fieldval) == -1) { - Py_DECREF(fieldval); - return false; - } - Py_DECREF(fieldval); - } - return true; -} - - -/* --- MAIN RECURSIVE INPUT FUCNTION --- */ - -// Returns a new reference. -static PyObject* -decode_val(DecodeBuffer* input, TType type, PyObject* typeargs) { - switch (type) { - - case T_BOOL: { - int8_t v = readByte(input); - if (INT_CONV_ERROR_OCCURRED(v)) { - return NULL; - } - - switch (v) { - case 0: Py_RETURN_FALSE; - case 1: Py_RETURN_TRUE; - // Don't laugh. This is a potentially serious issue. - default: PyErr_SetString(PyExc_TypeError, "boolean out of range"); return NULL; - } - break; - } - case T_I08: { - int8_t v = readByte(input); - if (INT_CONV_ERROR_OCCURRED(v)) { - return NULL; - } - - return PyInt_FromLong(v); - } - case T_I16: { - int16_t v = readI16(input); - if (INT_CONV_ERROR_OCCURRED(v)) { - return NULL; - } - return PyInt_FromLong(v); - } - case T_I32: { - int32_t v = readI32(input); - if (INT_CONV_ERROR_OCCURRED(v)) { - return NULL; - } - return PyInt_FromLong(v); - } - - case T_I64: { - int64_t v = readI64(input); - if (INT_CONV_ERROR_OCCURRED(v)) { - return NULL; - } - // TODO(dreiss): Find out if we can take this fastpath always when - // sizeof(long) == sizeof(long long). - if (CHECK_RANGE(v, LONG_MIN, LONG_MAX)) { - return PyInt_FromLong((long) v); - } - - return PyLong_FromLongLong(v); - } - - case T_DOUBLE: { - double v = readDouble(input); - if (v == -1.0 && PyErr_Occurred()) { - return false; - } - return PyFloat_FromDouble(v); - } - - case T_STRING: { - Py_ssize_t len = readI32(input); - char* buf; - if (!readBytes(input, &buf, len)) { - return NULL; - } - - return PyString_FromStringAndSize(buf, len); - } - - case T_LIST: - case T_SET: { - SetListTypeArgs parsedargs; - int32_t len; - PyObject* ret = NULL; - int i; - - if (!parse_set_list_args(&parsedargs, typeargs)) { - return NULL; - } - - if (!checkTypeByte(input, parsedargs.element_type)) { - return NULL; - } - - len = readI32(input); - if (!check_ssize_t_32(len)) { - return NULL; - } - - ret = PyList_New(len); - if (!ret) { - return NULL; - } - - for (i = 0; i < len; i++) { - PyObject* item = decode_val(input, parsedargs.element_type, parsedargs.typeargs); - if (!item) { - Py_DECREF(ret); - return NULL; - } - PyList_SET_ITEM(ret, i, item); - } - - // TODO(dreiss): Consider biting the bullet and making two separate cases - // for list and set, avoiding this post facto conversion. - if (type == T_SET) { - PyObject* setret; -#if (PY_VERSION_HEX < 0x02050000) - // hack needed for older versions - setret = PyObject_CallFunctionObjArgs((PyObject*)&PySet_Type, ret, NULL); -#else - // official version - setret = PySet_New(ret); -#endif - Py_DECREF(ret); - return setret; - } - return ret; - } - - case T_MAP: { - int32_t len; - int i; - MapTypeArgs parsedargs; - PyObject* ret = NULL; - - if (!parse_map_args(&parsedargs, typeargs)) { - return NULL; - } - - if (!checkTypeByte(input, parsedargs.ktag)) { - return NULL; - } - if (!checkTypeByte(input, parsedargs.vtag)) { - return NULL; - } - - len = readI32(input); - if (!check_ssize_t_32(len)) { - return false; - } - - ret = PyDict_New(); - if (!ret) { - goto error; - } - - for (i = 0; i < len; i++) { - PyObject* k = NULL; - PyObject* v = NULL; - k = decode_val(input, parsedargs.ktag, parsedargs.ktypeargs); - if (k == NULL) { - goto loop_error; - } - v = decode_val(input, parsedargs.vtag, parsedargs.vtypeargs); - if (v == NULL) { - goto loop_error; - } - if (PyDict_SetItem(ret, k, v) == -1) { - goto loop_error; - } - - Py_DECREF(k); - Py_DECREF(v); - continue; - - // Yuck! Destructors, anyone? - loop_error: - Py_XDECREF(k); - Py_XDECREF(v); - goto error; - } - - return ret; - - error: - Py_XDECREF(ret); - return NULL; - } - - case T_STRUCT: { - StructTypeArgs parsedargs; - PyObject* ret; - if (!parse_struct_args(&parsedargs, typeargs)) { - return NULL; - } - - ret = PyObject_CallObject(parsedargs.klass, NULL); - if (!ret) { - return NULL; - } - - if (!decode_struct(input, ret, parsedargs.spec)) { - Py_DECREF(ret); - return NULL; - } - - return ret; - } - - case T_STOP: - case T_VOID: - case T_UTF16: - case T_UTF8: - case T_U64: - default: - PyErr_SetString(PyExc_TypeError, "Unexpected TType"); - return NULL; - } -} - - -/* --- TOP-LEVEL WRAPPER FOR INPUT -- */ - -static PyObject* -decode_binary(PyObject *self, PyObject *args) { - PyObject* output_obj = NULL; - PyObject* transport = NULL; - PyObject* typeargs = NULL; - StructTypeArgs parsedargs; - DecodeBuffer input = {0, 0}; - - if (!PyArg_ParseTuple(args, "OOO", &output_obj, &transport, &typeargs)) { - return NULL; - } - - if (!parse_struct_args(&parsedargs, typeargs)) { - return NULL; - } - - if (!decode_buffer_from_obj(&input, transport)) { - return NULL; - } - - if (!decode_struct(&input, output_obj, parsedargs.spec)) { - free_decodebuf(&input); - return NULL; - } - - free_decodebuf(&input); - - Py_RETURN_NONE; -} - -/* ====== END READING FUNCTIONS ====== */ - - -/* -- PYTHON MODULE SETUP STUFF --- */ - -static PyMethodDef ThriftFastBinaryMethods[] = { - - {"encode_binary", encode_binary, METH_VARARGS, ""}, - {"decode_binary", decode_binary, METH_VARARGS, ""}, - - {NULL, NULL, 0, NULL} /* Sentinel */ -}; - -PyMODINIT_FUNC -initfastbinary(void) { -#define INIT_INTERN_STRING(value) \ - do { \ - INTERN_STRING(value) = PyString_InternFromString(#value); \ - if(!INTERN_STRING(value)) return; \ - } while(0) - - INIT_INTERN_STRING(cstringio_buf); - INIT_INTERN_STRING(cstringio_refill); -#undef INIT_INTERN_STRING - - PycString_IMPORT; - if (PycStringIO == NULL) return; - - (void) Py_InitModule("thrift.protocol.fastbinary", ThriftFastBinaryMethods); -} diff --git a/anknotes/thrift/server/THttpServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/server/THttpServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 3047d9c..0000000 --- a/anknotes/thrift/server/THttpServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,82 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -import BaseHTTPServer - -from thrift.server import TServer -from thrift.transport import TTransport - -class ResponseException(Exception): - """Allows handlers to override the HTTP response - - Normally, THttpServer always sends a 200 response. If a handler wants - to override this behavior (e.g., to simulate a misconfigured or - overloaded web server during testing), it can raise a ResponseException. - The function passed to the constructor will be called with the - RequestHandler as its only argument. - """ - def __init__(self, handler): - self.handler = handler - - -class THttpServer(TServer.TServer): - """A simple HTTP-based Thrift server - - This class is not very performant, but it is useful (for example) for - acting as a mock version of an Apache-based PHP Thrift endpoint.""" - - def __init__(self, processor, server_address, - inputProtocolFactory, outputProtocolFactory = None, - server_class = BaseHTTPServer.HTTPServer): - """Set up protocol factories and HTTP server. - - See BaseHTTPServer for server_address. - See TServer for protocol factories.""" - - if outputProtocolFactory is None: - outputProtocolFactory = inputProtocolFactory - - TServer.TServer.__init__(self, processor, None, None, None, - inputProtocolFactory, outputProtocolFactory) - - thttpserver = self - - class RequestHander(BaseHTTPServer.BaseHTTPRequestHandler): - def do_POST(self): - # Don't care about the request path. - itrans = TTransport.TFileObjectTransport(self.rfile) - otrans = TTransport.TFileObjectTransport(self.wfile) - itrans = TTransport.TBufferedTransport(itrans, int(self.headers['Content-Length'])) - otrans = TTransport.TMemoryBuffer() - iprot = thttpserver.inputProtocolFactory.getProtocol(itrans) - oprot = thttpserver.outputProtocolFactory.getProtocol(otrans) - try: - thttpserver.processor.process(iprot, oprot) - except ResponseException, exn: - exn.handler(self) - else: - self.send_response(200) - self.send_header("content-type", "application/x-thrift") - self.end_headers() - self.wfile.write(otrans.getvalue()) - - self.httpd = server_class(server_address, RequestHander) - - def serve(self): - self.httpd.serve_forever() diff --git a/anknotes/thrift/server/THttpServer.py~HEAD b/anknotes/thrift/server/THttpServer.py~HEAD deleted file mode 100644 index 3047d9c..0000000 --- a/anknotes/thrift/server/THttpServer.py~HEAD +++ /dev/null @@ -1,82 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -import BaseHTTPServer - -from thrift.server import TServer -from thrift.transport import TTransport - -class ResponseException(Exception): - """Allows handlers to override the HTTP response - - Normally, THttpServer always sends a 200 response. If a handler wants - to override this behavior (e.g., to simulate a misconfigured or - overloaded web server during testing), it can raise a ResponseException. - The function passed to the constructor will be called with the - RequestHandler as its only argument. - """ - def __init__(self, handler): - self.handler = handler - - -class THttpServer(TServer.TServer): - """A simple HTTP-based Thrift server - - This class is not very performant, but it is useful (for example) for - acting as a mock version of an Apache-based PHP Thrift endpoint.""" - - def __init__(self, processor, server_address, - inputProtocolFactory, outputProtocolFactory = None, - server_class = BaseHTTPServer.HTTPServer): - """Set up protocol factories and HTTP server. - - See BaseHTTPServer for server_address. - See TServer for protocol factories.""" - - if outputProtocolFactory is None: - outputProtocolFactory = inputProtocolFactory - - TServer.TServer.__init__(self, processor, None, None, None, - inputProtocolFactory, outputProtocolFactory) - - thttpserver = self - - class RequestHander(BaseHTTPServer.BaseHTTPRequestHandler): - def do_POST(self): - # Don't care about the request path. - itrans = TTransport.TFileObjectTransport(self.rfile) - otrans = TTransport.TFileObjectTransport(self.wfile) - itrans = TTransport.TBufferedTransport(itrans, int(self.headers['Content-Length'])) - otrans = TTransport.TMemoryBuffer() - iprot = thttpserver.inputProtocolFactory.getProtocol(itrans) - oprot = thttpserver.outputProtocolFactory.getProtocol(otrans) - try: - thttpserver.processor.process(iprot, oprot) - except ResponseException, exn: - exn.handler(self) - else: - self.send_response(200) - self.send_header("content-type", "application/x-thrift") - self.end_headers() - self.wfile.write(otrans.getvalue()) - - self.httpd = server_class(server_address, RequestHander) - - def serve(self): - self.httpd.serve_forever() diff --git a/anknotes/thrift/server/TNonblockingServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/server/TNonblockingServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index ea348a0..0000000 --- a/anknotes/thrift/server/TNonblockingServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,310 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -"""Implementation of non-blocking server. - -The main idea of the server is reciving and sending requests -only from main thread. - -It also makes thread pool server in tasks terms, not connections. -""" -import threading -import socket -import Queue -import select -import struct -import logging - -from thrift.transport import TTransport -from thrift.protocol.TBinaryProtocol import TBinaryProtocolFactory - -__all__ = ['TNonblockingServer'] - -class Worker(threading.Thread): - """Worker is a small helper to process incoming connection.""" - def __init__(self, queue): - threading.Thread.__init__(self) - self.queue = queue - - def run(self): - """Process queries from task queue, stop if processor is None.""" - while True: - try: - processor, iprot, oprot, otrans, callback = self.queue.get() - if processor is None: - break - processor.process(iprot, oprot) - callback(True, otrans.getvalue()) - except Exception: - logging.exception("Exception while processing request") - callback(False, '') - -WAIT_LEN = 0 -WAIT_MESSAGE = 1 -WAIT_PROCESS = 2 -SEND_ANSWER = 3 -CLOSED = 4 - -def locked(func): - "Decorator which locks self.lock." - def nested(self, *args, **kwargs): - self.lock.acquire() - try: - return func(self, *args, **kwargs) - finally: - self.lock.release() - return nested - -def socket_exception(func): - "Decorator close object on socket.error." - def read(self, *args, **kwargs): - try: - return func(self, *args, **kwargs) - except socket.error: - self.close() - return read - -class Connection: - """Basic class is represented connection. - - It can be in state: - WAIT_LEN --- connection is reading request len. - WAIT_MESSAGE --- connection is reading request. - WAIT_PROCESS --- connection has just read whole request and - waits for call ready routine. - SEND_ANSWER --- connection is sending answer string (including length - of answer). - CLOSED --- socket was closed and connection should be deleted. - """ - def __init__(self, new_socket, wake_up): - self.socket = new_socket - self.socket.setblocking(False) - self.status = WAIT_LEN - self.len = 0 - self.message = '' - self.lock = threading.Lock() - self.wake_up = wake_up - - def _read_len(self): - """Reads length of request. - - It's really paranoic routine and it may be replaced by - self.socket.recv(4).""" - read = self.socket.recv(4 - len(self.message)) - if len(read) == 0: - # if we read 0 bytes and self.message is empty, it means client close - # connection - if len(self.message) != 0: - logging.error("can't read frame size from socket") - self.close() - return - self.message += read - if len(self.message) == 4: - self.len, = struct.unpack('!i', self.message) - if self.len < 0: - logging.error("negative frame size, it seems client"\ - " doesn't use FramedTransport") - self.close() - elif self.len == 0: - logging.error("empty frame, it's really strange") - self.close() - else: - self.message = '' - self.status = WAIT_MESSAGE - - @socket_exception - def read(self): - """Reads data from stream and switch state.""" - assert self.status in (WAIT_LEN, WAIT_MESSAGE) - if self.status == WAIT_LEN: - self._read_len() - # go back to the main loop here for simplicity instead of - # falling through, even though there is a good chance that - # the message is already available - elif self.status == WAIT_MESSAGE: - read = self.socket.recv(self.len - len(self.message)) - if len(read) == 0: - logging.error("can't read frame from socket (get %d of %d bytes)" % - (len(self.message), self.len)) - self.close() - return - self.message += read - if len(self.message) == self.len: - self.status = WAIT_PROCESS - - @socket_exception - def write(self): - """Writes data from socket and switch state.""" - assert self.status == SEND_ANSWER - sent = self.socket.send(self.message) - if sent == len(self.message): - self.status = WAIT_LEN - self.message = '' - self.len = 0 - else: - self.message = self.message[sent:] - - @locked - def ready(self, all_ok, message): - """Callback function for switching state and waking up main thread. - - This function is the only function witch can be called asynchronous. - - The ready can switch Connection to three states: - WAIT_LEN if request was oneway. - SEND_ANSWER if request was processed in normal way. - CLOSED if request throws unexpected exception. - - The one wakes up main thread. - """ - assert self.status == WAIT_PROCESS - if not all_ok: - self.close() - self.wake_up() - return - self.len = '' - if len(message) == 0: - # it was a oneway request, do not write answer - self.message = '' - self.status = WAIT_LEN - else: - self.message = struct.pack('!i', len(message)) + message - self.status = SEND_ANSWER - self.wake_up() - - @locked - def is_writeable(self): - "Returns True if connection should be added to write list of select." - return self.status == SEND_ANSWER - - # it's not necessary, but... - @locked - def is_readable(self): - "Returns True if connection should be added to read list of select." - return self.status in (WAIT_LEN, WAIT_MESSAGE) - - @locked - def is_closed(self): - "Returns True if connection is closed." - return self.status == CLOSED - - def fileno(self): - "Returns the file descriptor of the associated socket." - return self.socket.fileno() - - def close(self): - "Closes connection" - self.status = CLOSED - self.socket.close() - -class TNonblockingServer: - """Non-blocking server.""" - def __init__(self, processor, lsocket, inputProtocolFactory=None, - outputProtocolFactory=None, threads=10): - self.processor = processor - self.socket = lsocket - self.in_protocol = inputProtocolFactory or TBinaryProtocolFactory() - self.out_protocol = outputProtocolFactory or self.in_protocol - self.threads = int(threads) - self.clients = {} - self.tasks = Queue.Queue() - self._read, self._write = socket.socketpair() - self.prepared = False - - def setNumThreads(self, num): - """Set the number of worker threads that should be created.""" - # implement ThreadPool interface - assert not self.prepared, "You can't change number of threads for working server" - self.threads = num - - def prepare(self): - """Prepares server for serve requests.""" - self.socket.listen() - for _ in xrange(self.threads): - thread = Worker(self.tasks) - thread.setDaemon(True) - thread.start() - self.prepared = True - - def wake_up(self): - """Wake up main thread. - - The server usualy waits in select call in we should terminate one. - The simplest way is using socketpair. - - Select always wait to read from the first socket of socketpair. - - In this case, we can just write anything to the second socket from - socketpair.""" - self._write.send('1') - - def _select(self): - """Does select on open connections.""" - readable = [self.socket.handle.fileno(), self._read.fileno()] - writable = [] - for i, connection in self.clients.items(): - if connection.is_readable(): - readable.append(connection.fileno()) - if connection.is_writeable(): - writable.append(connection.fileno()) - if connection.is_closed(): - del self.clients[i] - return select.select(readable, writable, readable) - - def handle(self): - """Handle requests. - - WARNING! You must call prepare BEFORE calling handle. - """ - assert self.prepared, "You have to call prepare before handle" - rset, wset, xset = self._select() - for readable in rset: - if readable == self._read.fileno(): - # don't care i just need to clean readable flag - self._read.recv(1024) - elif readable == self.socket.handle.fileno(): - client = self.socket.accept().handle - self.clients[client.fileno()] = Connection(client, self.wake_up) - else: - connection = self.clients[readable] - connection.read() - if connection.status == WAIT_PROCESS: - itransport = TTransport.TMemoryBuffer(connection.message) - otransport = TTransport.TMemoryBuffer() - iprot = self.in_protocol.getProtocol(itransport) - oprot = self.out_protocol.getProtocol(otransport) - self.tasks.put([self.processor, iprot, oprot, - otransport, connection.ready]) - for writeable in wset: - self.clients[writeable].write() - for oob in xset: - self.clients[oob].close() - del self.clients[oob] - - def close(self): - """Closes the server.""" - for _ in xrange(self.threads): - self.tasks.put([None, None, None, None, None]) - self.socket.close() - self.prepared = False - - def serve(self): - """Serve forever.""" - self.prepare() - while True: - self.handle() diff --git a/anknotes/thrift/server/TNonblockingServer.py~HEAD b/anknotes/thrift/server/TNonblockingServer.py~HEAD deleted file mode 100644 index ea348a0..0000000 --- a/anknotes/thrift/server/TNonblockingServer.py~HEAD +++ /dev/null @@ -1,310 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -"""Implementation of non-blocking server. - -The main idea of the server is reciving and sending requests -only from main thread. - -It also makes thread pool server in tasks terms, not connections. -""" -import threading -import socket -import Queue -import select -import struct -import logging - -from thrift.transport import TTransport -from thrift.protocol.TBinaryProtocol import TBinaryProtocolFactory - -__all__ = ['TNonblockingServer'] - -class Worker(threading.Thread): - """Worker is a small helper to process incoming connection.""" - def __init__(self, queue): - threading.Thread.__init__(self) - self.queue = queue - - def run(self): - """Process queries from task queue, stop if processor is None.""" - while True: - try: - processor, iprot, oprot, otrans, callback = self.queue.get() - if processor is None: - break - processor.process(iprot, oprot) - callback(True, otrans.getvalue()) - except Exception: - logging.exception("Exception while processing request") - callback(False, '') - -WAIT_LEN = 0 -WAIT_MESSAGE = 1 -WAIT_PROCESS = 2 -SEND_ANSWER = 3 -CLOSED = 4 - -def locked(func): - "Decorator which locks self.lock." - def nested(self, *args, **kwargs): - self.lock.acquire() - try: - return func(self, *args, **kwargs) - finally: - self.lock.release() - return nested - -def socket_exception(func): - "Decorator close object on socket.error." - def read(self, *args, **kwargs): - try: - return func(self, *args, **kwargs) - except socket.error: - self.close() - return read - -class Connection: - """Basic class is represented connection. - - It can be in state: - WAIT_LEN --- connection is reading request len. - WAIT_MESSAGE --- connection is reading request. - WAIT_PROCESS --- connection has just read whole request and - waits for call ready routine. - SEND_ANSWER --- connection is sending answer string (including length - of answer). - CLOSED --- socket was closed and connection should be deleted. - """ - def __init__(self, new_socket, wake_up): - self.socket = new_socket - self.socket.setblocking(False) - self.status = WAIT_LEN - self.len = 0 - self.message = '' - self.lock = threading.Lock() - self.wake_up = wake_up - - def _read_len(self): - """Reads length of request. - - It's really paranoic routine and it may be replaced by - self.socket.recv(4).""" - read = self.socket.recv(4 - len(self.message)) - if len(read) == 0: - # if we read 0 bytes and self.message is empty, it means client close - # connection - if len(self.message) != 0: - logging.error("can't read frame size from socket") - self.close() - return - self.message += read - if len(self.message) == 4: - self.len, = struct.unpack('!i', self.message) - if self.len < 0: - logging.error("negative frame size, it seems client"\ - " doesn't use FramedTransport") - self.close() - elif self.len == 0: - logging.error("empty frame, it's really strange") - self.close() - else: - self.message = '' - self.status = WAIT_MESSAGE - - @socket_exception - def read(self): - """Reads data from stream and switch state.""" - assert self.status in (WAIT_LEN, WAIT_MESSAGE) - if self.status == WAIT_LEN: - self._read_len() - # go back to the main loop here for simplicity instead of - # falling through, even though there is a good chance that - # the message is already available - elif self.status == WAIT_MESSAGE: - read = self.socket.recv(self.len - len(self.message)) - if len(read) == 0: - logging.error("can't read frame from socket (get %d of %d bytes)" % - (len(self.message), self.len)) - self.close() - return - self.message += read - if len(self.message) == self.len: - self.status = WAIT_PROCESS - - @socket_exception - def write(self): - """Writes data from socket and switch state.""" - assert self.status == SEND_ANSWER - sent = self.socket.send(self.message) - if sent == len(self.message): - self.status = WAIT_LEN - self.message = '' - self.len = 0 - else: - self.message = self.message[sent:] - - @locked - def ready(self, all_ok, message): - """Callback function for switching state and waking up main thread. - - This function is the only function witch can be called asynchronous. - - The ready can switch Connection to three states: - WAIT_LEN if request was oneway. - SEND_ANSWER if request was processed in normal way. - CLOSED if request throws unexpected exception. - - The one wakes up main thread. - """ - assert self.status == WAIT_PROCESS - if not all_ok: - self.close() - self.wake_up() - return - self.len = '' - if len(message) == 0: - # it was a oneway request, do not write answer - self.message = '' - self.status = WAIT_LEN - else: - self.message = struct.pack('!i', len(message)) + message - self.status = SEND_ANSWER - self.wake_up() - - @locked - def is_writeable(self): - "Returns True if connection should be added to write list of select." - return self.status == SEND_ANSWER - - # it's not necessary, but... - @locked - def is_readable(self): - "Returns True if connection should be added to read list of select." - return self.status in (WAIT_LEN, WAIT_MESSAGE) - - @locked - def is_closed(self): - "Returns True if connection is closed." - return self.status == CLOSED - - def fileno(self): - "Returns the file descriptor of the associated socket." - return self.socket.fileno() - - def close(self): - "Closes connection" - self.status = CLOSED - self.socket.close() - -class TNonblockingServer: - """Non-blocking server.""" - def __init__(self, processor, lsocket, inputProtocolFactory=None, - outputProtocolFactory=None, threads=10): - self.processor = processor - self.socket = lsocket - self.in_protocol = inputProtocolFactory or TBinaryProtocolFactory() - self.out_protocol = outputProtocolFactory or self.in_protocol - self.threads = int(threads) - self.clients = {} - self.tasks = Queue.Queue() - self._read, self._write = socket.socketpair() - self.prepared = False - - def setNumThreads(self, num): - """Set the number of worker threads that should be created.""" - # implement ThreadPool interface - assert not self.prepared, "You can't change number of threads for working server" - self.threads = num - - def prepare(self): - """Prepares server for serve requests.""" - self.socket.listen() - for _ in xrange(self.threads): - thread = Worker(self.tasks) - thread.setDaemon(True) - thread.start() - self.prepared = True - - def wake_up(self): - """Wake up main thread. - - The server usualy waits in select call in we should terminate one. - The simplest way is using socketpair. - - Select always wait to read from the first socket of socketpair. - - In this case, we can just write anything to the second socket from - socketpair.""" - self._write.send('1') - - def _select(self): - """Does select on open connections.""" - readable = [self.socket.handle.fileno(), self._read.fileno()] - writable = [] - for i, connection in self.clients.items(): - if connection.is_readable(): - readable.append(connection.fileno()) - if connection.is_writeable(): - writable.append(connection.fileno()) - if connection.is_closed(): - del self.clients[i] - return select.select(readable, writable, readable) - - def handle(self): - """Handle requests. - - WARNING! You must call prepare BEFORE calling handle. - """ - assert self.prepared, "You have to call prepare before handle" - rset, wset, xset = self._select() - for readable in rset: - if readable == self._read.fileno(): - # don't care i just need to clean readable flag - self._read.recv(1024) - elif readable == self.socket.handle.fileno(): - client = self.socket.accept().handle - self.clients[client.fileno()] = Connection(client, self.wake_up) - else: - connection = self.clients[readable] - connection.read() - if connection.status == WAIT_PROCESS: - itransport = TTransport.TMemoryBuffer(connection.message) - otransport = TTransport.TMemoryBuffer() - iprot = self.in_protocol.getProtocol(itransport) - oprot = self.out_protocol.getProtocol(otransport) - self.tasks.put([self.processor, iprot, oprot, - otransport, connection.ready]) - for writeable in wset: - self.clients[writeable].write() - for oob in xset: - self.clients[oob].close() - del self.clients[oob] - - def close(self): - """Closes the server.""" - for _ in xrange(self.threads): - self.tasks.put([None, None, None, None, None]) - self.socket.close() - self.prepared = False - - def serve(self): - """Serve forever.""" - self.prepare() - while True: - self.handle() diff --git a/anknotes/thrift/server/TProcessPoolServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/server/TProcessPoolServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 7ed814a..0000000 --- a/anknotes/thrift/server/TProcessPoolServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,125 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - - -import logging -from multiprocessing import Process, Value, Condition, reduction - -from TServer import TServer -from thrift.transport.TTransport import TTransportException - -class TProcessPoolServer(TServer): - - """ - Server with a fixed size pool of worker subprocesses which service requests. - Note that if you need shared state between the handlers - it's up to you! - Written by Dvir Volk, doat.com - """ - - def __init__(self, * args): - TServer.__init__(self, *args) - self.numWorkers = 10 - self.workers = [] - self.isRunning = Value('b', False) - self.stopCondition = Condition() - self.postForkCallback = None - - def setPostForkCallback(self, callback): - if not callable(callback): - raise TypeError("This is not a callback!") - self.postForkCallback = callback - - def setNumWorkers(self, num): - """Set the number of worker threads that should be created""" - self.numWorkers = num - - def workerProcess(self): - """Loop around getting clients from the shared queue and process them.""" - - if self.postForkCallback: - self.postForkCallback() - - while self.isRunning.value == True: - try: - client = self.serverTransport.accept() - self.serveClient(client) - except (KeyboardInterrupt, SystemExit): - return 0 - except Exception, x: - logging.exception(x) - - def serveClient(self, client): - """Process input/output from a client for as long as possible""" - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - iprot = self.inputProtocolFactory.getProtocol(itrans) - oprot = self.outputProtocolFactory.getProtocol(otrans) - - try: - while True: - self.processor.process(iprot, oprot) - except TTransportException, tx: - pass - except Exception, x: - logging.exception(x) - - itrans.close() - otrans.close() - - - def serve(self): - """Start a fixed number of worker threads and put client into a queue""" - - #this is a shared state that can tell the workers to exit when set as false - self.isRunning.value = True - - #first bind and listen to the port - self.serverTransport.listen() - - #fork the children - for i in range(self.numWorkers): - try: - w = Process(target=self.workerProcess) - w.daemon = True - w.start() - self.workers.append(w) - except Exception, x: - logging.exception(x) - - #wait until the condition is set by stop() - - while True: - - self.stopCondition.acquire() - try: - self.stopCondition.wait() - break - except (SystemExit, KeyboardInterrupt): - break - except Exception, x: - logging.exception(x) - - self.isRunning.value = False - - def stop(self): - self.isRunning.value = False - self.stopCondition.acquire() - self.stopCondition.notify() - self.stopCondition.release() - diff --git a/anknotes/thrift/server/TProcessPoolServer.py~HEAD b/anknotes/thrift/server/TProcessPoolServer.py~HEAD deleted file mode 100644 index 7ed814a..0000000 --- a/anknotes/thrift/server/TProcessPoolServer.py~HEAD +++ /dev/null @@ -1,125 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - - -import logging -from multiprocessing import Process, Value, Condition, reduction - -from TServer import TServer -from thrift.transport.TTransport import TTransportException - -class TProcessPoolServer(TServer): - - """ - Server with a fixed size pool of worker subprocesses which service requests. - Note that if you need shared state between the handlers - it's up to you! - Written by Dvir Volk, doat.com - """ - - def __init__(self, * args): - TServer.__init__(self, *args) - self.numWorkers = 10 - self.workers = [] - self.isRunning = Value('b', False) - self.stopCondition = Condition() - self.postForkCallback = None - - def setPostForkCallback(self, callback): - if not callable(callback): - raise TypeError("This is not a callback!") - self.postForkCallback = callback - - def setNumWorkers(self, num): - """Set the number of worker threads that should be created""" - self.numWorkers = num - - def workerProcess(self): - """Loop around getting clients from the shared queue and process them.""" - - if self.postForkCallback: - self.postForkCallback() - - while self.isRunning.value == True: - try: - client = self.serverTransport.accept() - self.serveClient(client) - except (KeyboardInterrupt, SystemExit): - return 0 - except Exception, x: - logging.exception(x) - - def serveClient(self, client): - """Process input/output from a client for as long as possible""" - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - iprot = self.inputProtocolFactory.getProtocol(itrans) - oprot = self.outputProtocolFactory.getProtocol(otrans) - - try: - while True: - self.processor.process(iprot, oprot) - except TTransportException, tx: - pass - except Exception, x: - logging.exception(x) - - itrans.close() - otrans.close() - - - def serve(self): - """Start a fixed number of worker threads and put client into a queue""" - - #this is a shared state that can tell the workers to exit when set as false - self.isRunning.value = True - - #first bind and listen to the port - self.serverTransport.listen() - - #fork the children - for i in range(self.numWorkers): - try: - w = Process(target=self.workerProcess) - w.daemon = True - w.start() - self.workers.append(w) - except Exception, x: - logging.exception(x) - - #wait until the condition is set by stop() - - while True: - - self.stopCondition.acquire() - try: - self.stopCondition.wait() - break - except (SystemExit, KeyboardInterrupt): - break - except Exception, x: - logging.exception(x) - - self.isRunning.value = False - - def stop(self): - self.isRunning.value = False - self.stopCondition.acquire() - self.stopCondition.notify() - self.stopCondition.release() - diff --git a/anknotes/thrift/server/TServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/server/TServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 8456e2d..0000000 --- a/anknotes/thrift/server/TServer.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,274 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -import logging -import sys -import os -import traceback -import threading -import Queue - -from thrift.Thrift import TProcessor -from thrift.transport import TTransport -from thrift.protocol import TBinaryProtocol - -class TServer: - - """Base interface for a server, which must have a serve method.""" - - """ 3 constructors for all servers: - 1) (processor, serverTransport) - 2) (processor, serverTransport, transportFactory, protocolFactory) - 3) (processor, serverTransport, - inputTransportFactory, outputTransportFactory, - inputProtocolFactory, outputProtocolFactory)""" - def __init__(self, *args): - if (len(args) == 2): - self.__initArgs__(args[0], args[1], - TTransport.TTransportFactoryBase(), - TTransport.TTransportFactoryBase(), - TBinaryProtocol.TBinaryProtocolFactory(), - TBinaryProtocol.TBinaryProtocolFactory()) - elif (len(args) == 4): - self.__initArgs__(args[0], args[1], args[2], args[2], args[3], args[3]) - elif (len(args) == 6): - self.__initArgs__(args[0], args[1], args[2], args[3], args[4], args[5]) - - def __initArgs__(self, processor, serverTransport, - inputTransportFactory, outputTransportFactory, - inputProtocolFactory, outputProtocolFactory): - self.processor = processor - self.serverTransport = serverTransport - self.inputTransportFactory = inputTransportFactory - self.outputTransportFactory = outputTransportFactory - self.inputProtocolFactory = inputProtocolFactory - self.outputProtocolFactory = outputProtocolFactory - - def serve(self): - pass - -class TSimpleServer(TServer): - - """Simple single-threaded server that just pumps around one transport.""" - - def __init__(self, *args): - TServer.__init__(self, *args) - - def serve(self): - self.serverTransport.listen() - while True: - client = self.serverTransport.accept() - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - iprot = self.inputProtocolFactory.getProtocol(itrans) - oprot = self.outputProtocolFactory.getProtocol(otrans) - try: - while True: - self.processor.process(iprot, oprot) - except TTransport.TTransportException, tx: - pass - except Exception, x: - logging.exception(x) - - itrans.close() - otrans.close() - -class TThreadedServer(TServer): - - """Threaded server that spawns a new thread per each connection.""" - - def __init__(self, *args, **kwargs): - TServer.__init__(self, *args) - self.daemon = kwargs.get("daemon", False) - - def serve(self): - self.serverTransport.listen() - while True: - try: - client = self.serverTransport.accept() - t = threading.Thread(target = self.handle, args=(client,)) - t.setDaemon(self.daemon) - t.start() - except KeyboardInterrupt: - raise - except Exception, x: - logging.exception(x) - - def handle(self, client): - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - iprot = self.inputProtocolFactory.getProtocol(itrans) - oprot = self.outputProtocolFactory.getProtocol(otrans) - try: - while True: - self.processor.process(iprot, oprot) - except TTransport.TTransportException, tx: - pass - except Exception, x: - logging.exception(x) - - itrans.close() - otrans.close() - -class TThreadPoolServer(TServer): - - """Server with a fixed size pool of threads which service requests.""" - - def __init__(self, *args, **kwargs): - TServer.__init__(self, *args) - self.clients = Queue.Queue() - self.threads = 10 - self.daemon = kwargs.get("daemon", False) - - def setNumThreads(self, num): - """Set the number of worker threads that should be created""" - self.threads = num - - def serveThread(self): - """Loop around getting clients from the shared queue and process them.""" - while True: - try: - client = self.clients.get() - self.serveClient(client) - except Exception, x: - logging.exception(x) - - def serveClient(self, client): - """Process input/output from a client for as long as possible""" - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - iprot = self.inputProtocolFactory.getProtocol(itrans) - oprot = self.outputProtocolFactory.getProtocol(otrans) - try: - while True: - self.processor.process(iprot, oprot) - except TTransport.TTransportException, tx: - pass - except Exception, x: - logging.exception(x) - - itrans.close() - otrans.close() - - def serve(self): - """Start a fixed number of worker threads and put client into a queue""" - for i in range(self.threads): - try: - t = threading.Thread(target = self.serveThread) - t.setDaemon(self.daemon) - t.start() - except Exception, x: - logging.exception(x) - - # Pump the socket for clients - self.serverTransport.listen() - while True: - try: - client = self.serverTransport.accept() - self.clients.put(client) - except Exception, x: - logging.exception(x) - - -class TForkingServer(TServer): - - """A Thrift server that forks a new process for each request""" - """ - This is more scalable than the threaded server as it does not cause - GIL contention. - - Note that this has different semantics from the threading server. - Specifically, updates to shared variables will no longer be shared. - It will also not work on windows. - - This code is heavily inspired by SocketServer.ForkingMixIn in the - Python stdlib. - """ - - def __init__(self, *args): - TServer.__init__(self, *args) - self.children = [] - - def serve(self): - def try_close(file): - try: - file.close() - except IOError, e: - logging.warning(e, exc_info=True) - - - self.serverTransport.listen() - while True: - client = self.serverTransport.accept() - try: - pid = os.fork() - - if pid: # parent - # add before collect, otherwise you race w/ waitpid - self.children.append(pid) - self.collect_children() - - # Parent must close socket or the connection may not get - # closed promptly - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - try_close(itrans) - try_close(otrans) - else: - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - - iprot = self.inputProtocolFactory.getProtocol(itrans) - oprot = self.outputProtocolFactory.getProtocol(otrans) - - ecode = 0 - try: - try: - while True: - self.processor.process(iprot, oprot) - except TTransport.TTransportException, tx: - pass - except Exception, e: - logging.exception(e) - ecode = 1 - finally: - try_close(itrans) - try_close(otrans) - - os._exit(ecode) - - except TTransport.TTransportException, tx: - pass - except Exception, x: - logging.exception(x) - - - def collect_children(self): - while self.children: - try: - pid, status = os.waitpid(0, os.WNOHANG) - except os.error: - pid = None - - if pid: - self.children.remove(pid) - else: - break - - diff --git a/anknotes/thrift/server/TServer.py~HEAD b/anknotes/thrift/server/TServer.py~HEAD deleted file mode 100644 index 8456e2d..0000000 --- a/anknotes/thrift/server/TServer.py~HEAD +++ /dev/null @@ -1,274 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -import logging -import sys -import os -import traceback -import threading -import Queue - -from thrift.Thrift import TProcessor -from thrift.transport import TTransport -from thrift.protocol import TBinaryProtocol - -class TServer: - - """Base interface for a server, which must have a serve method.""" - - """ 3 constructors for all servers: - 1) (processor, serverTransport) - 2) (processor, serverTransport, transportFactory, protocolFactory) - 3) (processor, serverTransport, - inputTransportFactory, outputTransportFactory, - inputProtocolFactory, outputProtocolFactory)""" - def __init__(self, *args): - if (len(args) == 2): - self.__initArgs__(args[0], args[1], - TTransport.TTransportFactoryBase(), - TTransport.TTransportFactoryBase(), - TBinaryProtocol.TBinaryProtocolFactory(), - TBinaryProtocol.TBinaryProtocolFactory()) - elif (len(args) == 4): - self.__initArgs__(args[0], args[1], args[2], args[2], args[3], args[3]) - elif (len(args) == 6): - self.__initArgs__(args[0], args[1], args[2], args[3], args[4], args[5]) - - def __initArgs__(self, processor, serverTransport, - inputTransportFactory, outputTransportFactory, - inputProtocolFactory, outputProtocolFactory): - self.processor = processor - self.serverTransport = serverTransport - self.inputTransportFactory = inputTransportFactory - self.outputTransportFactory = outputTransportFactory - self.inputProtocolFactory = inputProtocolFactory - self.outputProtocolFactory = outputProtocolFactory - - def serve(self): - pass - -class TSimpleServer(TServer): - - """Simple single-threaded server that just pumps around one transport.""" - - def __init__(self, *args): - TServer.__init__(self, *args) - - def serve(self): - self.serverTransport.listen() - while True: - client = self.serverTransport.accept() - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - iprot = self.inputProtocolFactory.getProtocol(itrans) - oprot = self.outputProtocolFactory.getProtocol(otrans) - try: - while True: - self.processor.process(iprot, oprot) - except TTransport.TTransportException, tx: - pass - except Exception, x: - logging.exception(x) - - itrans.close() - otrans.close() - -class TThreadedServer(TServer): - - """Threaded server that spawns a new thread per each connection.""" - - def __init__(self, *args, **kwargs): - TServer.__init__(self, *args) - self.daemon = kwargs.get("daemon", False) - - def serve(self): - self.serverTransport.listen() - while True: - try: - client = self.serverTransport.accept() - t = threading.Thread(target = self.handle, args=(client,)) - t.setDaemon(self.daemon) - t.start() - except KeyboardInterrupt: - raise - except Exception, x: - logging.exception(x) - - def handle(self, client): - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - iprot = self.inputProtocolFactory.getProtocol(itrans) - oprot = self.outputProtocolFactory.getProtocol(otrans) - try: - while True: - self.processor.process(iprot, oprot) - except TTransport.TTransportException, tx: - pass - except Exception, x: - logging.exception(x) - - itrans.close() - otrans.close() - -class TThreadPoolServer(TServer): - - """Server with a fixed size pool of threads which service requests.""" - - def __init__(self, *args, **kwargs): - TServer.__init__(self, *args) - self.clients = Queue.Queue() - self.threads = 10 - self.daemon = kwargs.get("daemon", False) - - def setNumThreads(self, num): - """Set the number of worker threads that should be created""" - self.threads = num - - def serveThread(self): - """Loop around getting clients from the shared queue and process them.""" - while True: - try: - client = self.clients.get() - self.serveClient(client) - except Exception, x: - logging.exception(x) - - def serveClient(self, client): - """Process input/output from a client for as long as possible""" - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - iprot = self.inputProtocolFactory.getProtocol(itrans) - oprot = self.outputProtocolFactory.getProtocol(otrans) - try: - while True: - self.processor.process(iprot, oprot) - except TTransport.TTransportException, tx: - pass - except Exception, x: - logging.exception(x) - - itrans.close() - otrans.close() - - def serve(self): - """Start a fixed number of worker threads and put client into a queue""" - for i in range(self.threads): - try: - t = threading.Thread(target = self.serveThread) - t.setDaemon(self.daemon) - t.start() - except Exception, x: - logging.exception(x) - - # Pump the socket for clients - self.serverTransport.listen() - while True: - try: - client = self.serverTransport.accept() - self.clients.put(client) - except Exception, x: - logging.exception(x) - - -class TForkingServer(TServer): - - """A Thrift server that forks a new process for each request""" - """ - This is more scalable than the threaded server as it does not cause - GIL contention. - - Note that this has different semantics from the threading server. - Specifically, updates to shared variables will no longer be shared. - It will also not work on windows. - - This code is heavily inspired by SocketServer.ForkingMixIn in the - Python stdlib. - """ - - def __init__(self, *args): - TServer.__init__(self, *args) - self.children = [] - - def serve(self): - def try_close(file): - try: - file.close() - except IOError, e: - logging.warning(e, exc_info=True) - - - self.serverTransport.listen() - while True: - client = self.serverTransport.accept() - try: - pid = os.fork() - - if pid: # parent - # add before collect, otherwise you race w/ waitpid - self.children.append(pid) - self.collect_children() - - # Parent must close socket or the connection may not get - # closed promptly - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - try_close(itrans) - try_close(otrans) - else: - itrans = self.inputTransportFactory.getTransport(client) - otrans = self.outputTransportFactory.getTransport(client) - - iprot = self.inputProtocolFactory.getProtocol(itrans) - oprot = self.outputProtocolFactory.getProtocol(otrans) - - ecode = 0 - try: - try: - while True: - self.processor.process(iprot, oprot) - except TTransport.TTransportException, tx: - pass - except Exception, e: - logging.exception(e) - ecode = 1 - finally: - try_close(itrans) - try_close(otrans) - - os._exit(ecode) - - except TTransport.TTransportException, tx: - pass - except Exception, x: - logging.exception(x) - - - def collect_children(self): - while self.children: - try: - pid, status = os.waitpid(0, os.WNOHANG) - except os.error: - pid = None - - if pid: - self.children.remove(pid) - else: - break - - diff --git a/anknotes/thrift/server/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/server/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 1bf6e25..0000000 --- a/anknotes/thrift/server/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,20 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -__all__ = ['TServer', 'TNonblockingServer'] diff --git a/anknotes/thrift/server/__init__.py~HEAD b/anknotes/thrift/server/__init__.py~HEAD deleted file mode 100644 index 1bf6e25..0000000 --- a/anknotes/thrift/server/__init__.py~HEAD +++ /dev/null @@ -1,20 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -__all__ = ['TServer', 'TNonblockingServer'] diff --git a/anknotes/thrift/transport/THttpClient.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/transport/THttpClient.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index d74baa4..0000000 --- a/anknotes/thrift/transport/THttpClient.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,161 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from TTransport import TTransportBase -from cStringIO import StringIO - -import urlparse -import httplib -import warnings -import socket - - -class THttpClient(TTransportBase): - - """Http implementation of TTransport base.""" - - def __init__( - self, - uri_or_host, - port=None, - path=None, - proxy_host=None, - proxy_port=None - ): - """THttpClient supports two different types constructor parameters. - - THttpClient(host, port, path) - deprecated - THttpClient(uri) - - Only the second supports https.""" - - """THttpClient supports proxy - THttpClient(host, port, path, proxy_host, proxy_port) - deprecated - ThttpClient(uri, None, None, proxy_host, proxy_port)""" - - if port is not None: - warnings.warn( - "Please use the THttpClient('http://host:port/path') syntax", - DeprecationWarning, - stacklevel=2) - self.host = uri_or_host - self.port = port - assert path - self.path = path - self.scheme = 'http' - else: - parsed = urlparse.urlparse(uri_or_host) - self.scheme = parsed.scheme - assert self.scheme in ('http', 'https') - if self.scheme == 'http': - self.port = parsed.port or httplib.HTTP_PORT - elif self.scheme == 'https': - self.port = parsed.port or httplib.HTTPS_PORT - self.host = parsed.hostname - self.path = parsed.path - if parsed.query: - self.path += '?%s' % parsed.query - - if proxy_host is not None and proxy_port is not None: - self.endpoint_host = proxy_host - self.endpoint_port = proxy_port - self.path = urlparse.urlunparse(( - self.scheme, - "%s:%i" % (self.host, self.port), - self.path, - None, - None, - None - )) - else: - self.endpoint_host = self.host - self.endpoint_port = self.port - - self.__wbuf = StringIO() - self.__http = None - self.__timeout = None - self.__headers = {} - - def open(self): - protocol = httplib.HTTP if self.scheme == 'http' else httplib.HTTPS - self.__http = protocol(self.endpoint_host, self.endpoint_port) - - def close(self): - self.__http.close() - self.__http = None - - def isOpen(self): - return self.__http is not None - - def setTimeout(self, ms): - if not hasattr(socket, 'getdefaulttimeout'): - raise NotImplementedError - - if ms is None: - self.__timeout = None - else: - self.__timeout = ms / 1000.0 - - def read(self, sz): - return self.__http.file.read(sz) - - def write(self, buf): - self.__wbuf.write(buf) - - def __withTimeout(f): - def _f(*args, **kwargs): - orig_timeout = socket.getdefaulttimeout() - socket.setdefaulttimeout(args[0].__timeout) - result = f(*args, **kwargs) - socket.setdefaulttimeout(orig_timeout) - return result - return _f - - def addHeaders(self, **kwargs): - self.__headers.update(kwargs) - - def flush(self): - if self.isOpen(): - self.close() - self.open() - - # Pull data out of buffer - data = self.__wbuf.getvalue() - self.__wbuf = StringIO() - - # HTTP request - self.__http.putrequest('POST', self.path) - - # Write headers - self.__http.putheader('Host', self.host) - self.__http.putheader('Content-Type', 'application/x-thrift') - self.__http.putheader('Content-Length', str(len(data))) - for key, value in self.__headers.iteritems(): - self.__http.putheader(key, value) - self.__http.endheaders() - - # Write payload - self.__http.send(data) - - # Get reply to flush the request - self.code, self.message, self.headers = self.__http.getreply() - - # Decorate if we know how to timeout - if hasattr(socket, 'getdefaulttimeout'): - flush = __withTimeout(flush) diff --git a/anknotes/thrift/transport/THttpClient.py~HEAD b/anknotes/thrift/transport/THttpClient.py~HEAD deleted file mode 100644 index d74baa4..0000000 --- a/anknotes/thrift/transport/THttpClient.py~HEAD +++ /dev/null @@ -1,161 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from TTransport import TTransportBase -from cStringIO import StringIO - -import urlparse -import httplib -import warnings -import socket - - -class THttpClient(TTransportBase): - - """Http implementation of TTransport base.""" - - def __init__( - self, - uri_or_host, - port=None, - path=None, - proxy_host=None, - proxy_port=None - ): - """THttpClient supports two different types constructor parameters. - - THttpClient(host, port, path) - deprecated - THttpClient(uri) - - Only the second supports https.""" - - """THttpClient supports proxy - THttpClient(host, port, path, proxy_host, proxy_port) - deprecated - ThttpClient(uri, None, None, proxy_host, proxy_port)""" - - if port is not None: - warnings.warn( - "Please use the THttpClient('http://host:port/path') syntax", - DeprecationWarning, - stacklevel=2) - self.host = uri_or_host - self.port = port - assert path - self.path = path - self.scheme = 'http' - else: - parsed = urlparse.urlparse(uri_or_host) - self.scheme = parsed.scheme - assert self.scheme in ('http', 'https') - if self.scheme == 'http': - self.port = parsed.port or httplib.HTTP_PORT - elif self.scheme == 'https': - self.port = parsed.port or httplib.HTTPS_PORT - self.host = parsed.hostname - self.path = parsed.path - if parsed.query: - self.path += '?%s' % parsed.query - - if proxy_host is not None and proxy_port is not None: - self.endpoint_host = proxy_host - self.endpoint_port = proxy_port - self.path = urlparse.urlunparse(( - self.scheme, - "%s:%i" % (self.host, self.port), - self.path, - None, - None, - None - )) - else: - self.endpoint_host = self.host - self.endpoint_port = self.port - - self.__wbuf = StringIO() - self.__http = None - self.__timeout = None - self.__headers = {} - - def open(self): - protocol = httplib.HTTP if self.scheme == 'http' else httplib.HTTPS - self.__http = protocol(self.endpoint_host, self.endpoint_port) - - def close(self): - self.__http.close() - self.__http = None - - def isOpen(self): - return self.__http is not None - - def setTimeout(self, ms): - if not hasattr(socket, 'getdefaulttimeout'): - raise NotImplementedError - - if ms is None: - self.__timeout = None - else: - self.__timeout = ms / 1000.0 - - def read(self, sz): - return self.__http.file.read(sz) - - def write(self, buf): - self.__wbuf.write(buf) - - def __withTimeout(f): - def _f(*args, **kwargs): - orig_timeout = socket.getdefaulttimeout() - socket.setdefaulttimeout(args[0].__timeout) - result = f(*args, **kwargs) - socket.setdefaulttimeout(orig_timeout) - return result - return _f - - def addHeaders(self, **kwargs): - self.__headers.update(kwargs) - - def flush(self): - if self.isOpen(): - self.close() - self.open() - - # Pull data out of buffer - data = self.__wbuf.getvalue() - self.__wbuf = StringIO() - - # HTTP request - self.__http.putrequest('POST', self.path) - - # Write headers - self.__http.putheader('Host', self.host) - self.__http.putheader('Content-Type', 'application/x-thrift') - self.__http.putheader('Content-Length', str(len(data))) - for key, value in self.__headers.iteritems(): - self.__http.putheader(key, value) - self.__http.endheaders() - - # Write payload - self.__http.send(data) - - # Get reply to flush the request - self.code, self.message, self.headers = self.__http.getreply() - - # Decorate if we know how to timeout - if hasattr(socket, 'getdefaulttimeout'): - flush = __withTimeout(flush) diff --git a/anknotes/thrift/transport/TSSLSocket.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/transport/TSSLSocket.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index be35844..0000000 --- a/anknotes/thrift/transport/TSSLSocket.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,176 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -import os -import socket -import ssl - -from thrift.transport import TSocket -from thrift.transport.TTransport import TTransportException - -class TSSLSocket(TSocket.TSocket): - """ - SSL implementation of client-side TSocket - - This class creates outbound sockets wrapped using the - python standard ssl module for encrypted connections. - - The protocol used is set using the class variable - SSL_VERSION, which must be one of ssl.PROTOCOL_* and - defaults to ssl.PROTOCOL_TLSv1 for greatest security. - """ - SSL_VERSION = ssl.PROTOCOL_TLSv1 - - def __init__(self, host='localhost', port=9090, validate=True, ca_certs=None, unix_socket=None): - """ - @param validate: Set to False to disable SSL certificate validation entirely. - @type validate: bool - @param ca_certs: Filename to the Certificate Authority pem file, possibly a - file downloaded from: http://curl.haxx.se/ca/cacert.pem This is passed to - the ssl_wrap function as the 'ca_certs' parameter. - @type ca_certs: str - - Raises an IOError exception if validate is True and the ca_certs file is - None, not present or unreadable. - """ - self.validate = validate - self.is_valid = False - self.peercert = None - if not validate: - self.cert_reqs = ssl.CERT_NONE - else: - self.cert_reqs = ssl.CERT_REQUIRED - self.ca_certs = ca_certs - if validate: - if ca_certs is None or not os.access(ca_certs, os.R_OK): - raise IOError('Certificate Authority ca_certs file "%s" is not readable, cannot validate SSL certificates.' % (ca_certs)) - TSocket.TSocket.__init__(self, host, port, unix_socket) - - def open(self): - try: - res0 = self._resolveAddr() - for res in res0: - sock_family, sock_type= res[0:2] - ip_port = res[4] - plain_sock = socket.socket(sock_family, sock_type) - self.handle = ssl.wrap_socket(plain_sock, ssl_version=self.SSL_VERSION, - do_handshake_on_connect=True, ca_certs=self.ca_certs, cert_reqs=self.cert_reqs) - self.handle.settimeout(self._timeout) - try: - self.handle.connect(ip_port) - except socket.error, e: - if res is not res0[-1]: - continue - else: - raise e - break - except socket.error, e: - if self._unix_socket: - message = 'Could not connect to secure socket %s' % self._unix_socket - else: - message = 'Could not connect to %s:%d' % (self.host, self.port) - raise TTransportException(type=TTransportException.NOT_OPEN, message=message) - if self.validate: - self._validate_cert() - - def _validate_cert(self): - """internal method to validate the peer's SSL certificate, and to check the - commonName of the certificate to ensure it matches the hostname we - used to make this connection. Does not support subjectAltName records - in certificates. - - raises TTransportException if the certificate fails validation.""" - cert = self.handle.getpeercert() - self.peercert = cert - if 'subject' not in cert: - raise TTransportException(type=TTransportException.NOT_OPEN, - message='No SSL certificate found from %s:%s' % (self.host, self.port)) - fields = cert['subject'] - for field in fields: - # ensure structure we get back is what we expect - if not isinstance(field, tuple): - continue - cert_pair = field[0] - if len(cert_pair) < 2: - continue - cert_key, cert_value = cert_pair[0:2] - if cert_key != 'commonName': - continue - certhost = cert_value - if certhost == self.host: - # success, cert commonName matches desired hostname - self.is_valid = True - return - else: - raise TTransportException(type=TTransportException.UNKNOWN, - message='Host name we connected to "%s" doesn\'t match certificate provided commonName "%s"' % (self.host, certhost)) - raise TTransportException(type=TTransportException.UNKNOWN, - message='Could not validate SSL certificate from host "%s". Cert=%s' % (self.host, cert)) - -class TSSLServerSocket(TSocket.TServerSocket): - """ - SSL implementation of TServerSocket - - This uses the ssl module's wrap_socket() method to provide SSL - negotiated encryption. - """ - SSL_VERSION = ssl.PROTOCOL_TLSv1 - - def __init__(self, host=None, port=9090, certfile='cert.pem', unix_socket=None): - """Initialize a TSSLServerSocket - - @param certfile: The filename of the server certificate file, defaults to cert.pem - @type certfile: str - @param host: The hostname or IP to bind the listen socket to, i.e. 'localhost' for only allowing - local network connections. Pass None to bind to all interfaces. - @type host: str - @param port: The port to listen on for inbound connections. - @type port: int - """ - self.setCertfile(certfile) - TSocket.TServerSocket.__init__(self, host, port) - - def setCertfile(self, certfile): - """Set or change the server certificate file used to wrap new connections. - - @param certfile: The filename of the server certificate, i.e. '/etc/certs/server.pem' - @type certfile: str - - Raises an IOError exception if the certfile is not present or unreadable. - """ - if not os.access(certfile, os.R_OK): - raise IOError('No such certfile found: %s' % (certfile)) - self.certfile = certfile - - def accept(self): - plain_client, addr = self.handle.accept() - try: - client = ssl.wrap_socket(plain_client, certfile=self.certfile, - server_side=True, ssl_version=self.SSL_VERSION) - except ssl.SSLError, ssl_exc: - # failed handshake/ssl wrap, close socket to client - plain_client.close() - # raise ssl_exc - # We can't raise the exception, because it kills most TServer derived serve() - # methods. - # Instead, return None, and let the TServer instance deal with it in - # other exception handling. (but TSimpleServer dies anyway) - return None - result = TSocket.TSocket() - result.setHandle(client) - return result diff --git a/anknotes/thrift/transport/TSSLSocket.py~HEAD b/anknotes/thrift/transport/TSSLSocket.py~HEAD deleted file mode 100644 index be35844..0000000 --- a/anknotes/thrift/transport/TSSLSocket.py~HEAD +++ /dev/null @@ -1,176 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -import os -import socket -import ssl - -from thrift.transport import TSocket -from thrift.transport.TTransport import TTransportException - -class TSSLSocket(TSocket.TSocket): - """ - SSL implementation of client-side TSocket - - This class creates outbound sockets wrapped using the - python standard ssl module for encrypted connections. - - The protocol used is set using the class variable - SSL_VERSION, which must be one of ssl.PROTOCOL_* and - defaults to ssl.PROTOCOL_TLSv1 for greatest security. - """ - SSL_VERSION = ssl.PROTOCOL_TLSv1 - - def __init__(self, host='localhost', port=9090, validate=True, ca_certs=None, unix_socket=None): - """ - @param validate: Set to False to disable SSL certificate validation entirely. - @type validate: bool - @param ca_certs: Filename to the Certificate Authority pem file, possibly a - file downloaded from: http://curl.haxx.se/ca/cacert.pem This is passed to - the ssl_wrap function as the 'ca_certs' parameter. - @type ca_certs: str - - Raises an IOError exception if validate is True and the ca_certs file is - None, not present or unreadable. - """ - self.validate = validate - self.is_valid = False - self.peercert = None - if not validate: - self.cert_reqs = ssl.CERT_NONE - else: - self.cert_reqs = ssl.CERT_REQUIRED - self.ca_certs = ca_certs - if validate: - if ca_certs is None or not os.access(ca_certs, os.R_OK): - raise IOError('Certificate Authority ca_certs file "%s" is not readable, cannot validate SSL certificates.' % (ca_certs)) - TSocket.TSocket.__init__(self, host, port, unix_socket) - - def open(self): - try: - res0 = self._resolveAddr() - for res in res0: - sock_family, sock_type= res[0:2] - ip_port = res[4] - plain_sock = socket.socket(sock_family, sock_type) - self.handle = ssl.wrap_socket(plain_sock, ssl_version=self.SSL_VERSION, - do_handshake_on_connect=True, ca_certs=self.ca_certs, cert_reqs=self.cert_reqs) - self.handle.settimeout(self._timeout) - try: - self.handle.connect(ip_port) - except socket.error, e: - if res is not res0[-1]: - continue - else: - raise e - break - except socket.error, e: - if self._unix_socket: - message = 'Could not connect to secure socket %s' % self._unix_socket - else: - message = 'Could not connect to %s:%d' % (self.host, self.port) - raise TTransportException(type=TTransportException.NOT_OPEN, message=message) - if self.validate: - self._validate_cert() - - def _validate_cert(self): - """internal method to validate the peer's SSL certificate, and to check the - commonName of the certificate to ensure it matches the hostname we - used to make this connection. Does not support subjectAltName records - in certificates. - - raises TTransportException if the certificate fails validation.""" - cert = self.handle.getpeercert() - self.peercert = cert - if 'subject' not in cert: - raise TTransportException(type=TTransportException.NOT_OPEN, - message='No SSL certificate found from %s:%s' % (self.host, self.port)) - fields = cert['subject'] - for field in fields: - # ensure structure we get back is what we expect - if not isinstance(field, tuple): - continue - cert_pair = field[0] - if len(cert_pair) < 2: - continue - cert_key, cert_value = cert_pair[0:2] - if cert_key != 'commonName': - continue - certhost = cert_value - if certhost == self.host: - # success, cert commonName matches desired hostname - self.is_valid = True - return - else: - raise TTransportException(type=TTransportException.UNKNOWN, - message='Host name we connected to "%s" doesn\'t match certificate provided commonName "%s"' % (self.host, certhost)) - raise TTransportException(type=TTransportException.UNKNOWN, - message='Could not validate SSL certificate from host "%s". Cert=%s' % (self.host, cert)) - -class TSSLServerSocket(TSocket.TServerSocket): - """ - SSL implementation of TServerSocket - - This uses the ssl module's wrap_socket() method to provide SSL - negotiated encryption. - """ - SSL_VERSION = ssl.PROTOCOL_TLSv1 - - def __init__(self, host=None, port=9090, certfile='cert.pem', unix_socket=None): - """Initialize a TSSLServerSocket - - @param certfile: The filename of the server certificate file, defaults to cert.pem - @type certfile: str - @param host: The hostname or IP to bind the listen socket to, i.e. 'localhost' for only allowing - local network connections. Pass None to bind to all interfaces. - @type host: str - @param port: The port to listen on for inbound connections. - @type port: int - """ - self.setCertfile(certfile) - TSocket.TServerSocket.__init__(self, host, port) - - def setCertfile(self, certfile): - """Set or change the server certificate file used to wrap new connections. - - @param certfile: The filename of the server certificate, i.e. '/etc/certs/server.pem' - @type certfile: str - - Raises an IOError exception if the certfile is not present or unreadable. - """ - if not os.access(certfile, os.R_OK): - raise IOError('No such certfile found: %s' % (certfile)) - self.certfile = certfile - - def accept(self): - plain_client, addr = self.handle.accept() - try: - client = ssl.wrap_socket(plain_client, certfile=self.certfile, - server_side=True, ssl_version=self.SSL_VERSION) - except ssl.SSLError, ssl_exc: - # failed handshake/ssl wrap, close socket to client - plain_client.close() - # raise ssl_exc - # We can't raise the exception, because it kills most TServer derived serve() - # methods. - # Instead, return None, and let the TServer instance deal with it in - # other exception handling. (but TSimpleServer dies anyway) - return None - result = TSocket.TSocket() - result.setHandle(client) - return result diff --git a/anknotes/thrift/transport/TSocket.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/transport/TSocket.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 4e0e187..0000000 --- a/anknotes/thrift/transport/TSocket.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,163 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from TTransport import * -import os -import errno -import socket -import sys - -class TSocketBase(TTransportBase): - def _resolveAddr(self): - if self._unix_socket is not None: - return [(socket.AF_UNIX, socket.SOCK_STREAM, None, None, self._unix_socket)] - else: - return socket.getaddrinfo(self.host, self.port, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE | socket.AI_ADDRCONFIG) - - def close(self): - if self.handle: - self.handle.close() - self.handle = None - -class TSocket(TSocketBase): - """Socket implementation of TTransport base.""" - - def __init__(self, host='localhost', port=9090, unix_socket=None): - """Initialize a TSocket - - @param host(str) The host to connect to. - @param port(int) The (TCP) port to connect to. - @param unix_socket(str) The filename of a unix socket to connect to. - (host and port will be ignored.) - """ - - self.host = host - self.port = port - self.handle = None - self._unix_socket = unix_socket - self._timeout = None - - def setHandle(self, h): - self.handle = h - - def isOpen(self): - return self.handle is not None - - def setTimeout(self, ms): - if ms is None: - self._timeout = None - else: - self._timeout = ms/1000.0 - - if self.handle is not None: - self.handle.settimeout(self._timeout) - - def open(self): - try: - res0 = self._resolveAddr() - for res in res0: - self.handle = socket.socket(res[0], res[1]) - self.handle.settimeout(self._timeout) - try: - self.handle.connect(res[4]) - except socket.error, e: - if res is not res0[-1]: - continue - else: - raise e - break - except socket.error, e: - if self._unix_socket: - message = 'Could not connect to socket %s' % self._unix_socket - else: - message = 'Could not connect to %s:%d' % (self.host, self.port) - raise TTransportException(type=TTransportException.NOT_OPEN, message=message) - - def read(self, sz): - try: - buff = self.handle.recv(sz) - except socket.error, e: - if (e.args[0] == errno.ECONNRESET and - (sys.platform == 'darwin' or sys.platform.startswith('freebsd'))): - # freebsd and Mach don't follow POSIX semantic of recv - # and fail with ECONNRESET if peer performed shutdown. - # See corresponding comment and code in TSocket::read() - # in lib/cpp/src/transport/TSocket.cpp. - self.close() - # Trigger the check to raise the END_OF_FILE exception below. - buff = '' - else: - raise - if len(buff) == 0: - raise TTransportException(type=TTransportException.END_OF_FILE, message='TSocket read 0 bytes') - return buff - - def write(self, buff): - if not self.handle: - raise TTransportException(type=TTransportException.NOT_OPEN, message='Transport not open') - sent = 0 - have = len(buff) - while sent < have: - plus = self.handle.send(buff) - if plus == 0: - raise TTransportException(type=TTransportException.END_OF_FILE, message='TSocket sent 0 bytes') - sent += plus - buff = buff[plus:] - - def flush(self): - pass - -class TServerSocket(TSocketBase, TServerTransportBase): - """Socket implementation of TServerTransport base.""" - - def __init__(self, host=None, port=9090, unix_socket=None): - self.host = host - self.port = port - self._unix_socket = unix_socket - self.handle = None - - def listen(self): - res0 = self._resolveAddr() - for res in res0: - if res[0] is socket.AF_INET6 or res is res0[-1]: - break - - # We need remove the old unix socket if the file exists and - # nobody is listening on it. - if self._unix_socket: - tmp = socket.socket(res[0], res[1]) - try: - tmp.connect(res[4]) - except socket.error, err: - eno, message = err.args - if eno == errno.ECONNREFUSED: - os.unlink(res[4]) - - self.handle = socket.socket(res[0], res[1]) - self.handle.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - if hasattr(self.handle, 'settimeout'): - self.handle.settimeout(None) - self.handle.bind(res[4]) - self.handle.listen(128) - - def accept(self): - client, addr = self.handle.accept() - result = TSocket() - result.setHandle(client) - return result diff --git a/anknotes/thrift/transport/TSocket.py~HEAD b/anknotes/thrift/transport/TSocket.py~HEAD deleted file mode 100644 index 4e0e187..0000000 --- a/anknotes/thrift/transport/TSocket.py~HEAD +++ /dev/null @@ -1,163 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from TTransport import * -import os -import errno -import socket -import sys - -class TSocketBase(TTransportBase): - def _resolveAddr(self): - if self._unix_socket is not None: - return [(socket.AF_UNIX, socket.SOCK_STREAM, None, None, self._unix_socket)] - else: - return socket.getaddrinfo(self.host, self.port, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE | socket.AI_ADDRCONFIG) - - def close(self): - if self.handle: - self.handle.close() - self.handle = None - -class TSocket(TSocketBase): - """Socket implementation of TTransport base.""" - - def __init__(self, host='localhost', port=9090, unix_socket=None): - """Initialize a TSocket - - @param host(str) The host to connect to. - @param port(int) The (TCP) port to connect to. - @param unix_socket(str) The filename of a unix socket to connect to. - (host and port will be ignored.) - """ - - self.host = host - self.port = port - self.handle = None - self._unix_socket = unix_socket - self._timeout = None - - def setHandle(self, h): - self.handle = h - - def isOpen(self): - return self.handle is not None - - def setTimeout(self, ms): - if ms is None: - self._timeout = None - else: - self._timeout = ms/1000.0 - - if self.handle is not None: - self.handle.settimeout(self._timeout) - - def open(self): - try: - res0 = self._resolveAddr() - for res in res0: - self.handle = socket.socket(res[0], res[1]) - self.handle.settimeout(self._timeout) - try: - self.handle.connect(res[4]) - except socket.error, e: - if res is not res0[-1]: - continue - else: - raise e - break - except socket.error, e: - if self._unix_socket: - message = 'Could not connect to socket %s' % self._unix_socket - else: - message = 'Could not connect to %s:%d' % (self.host, self.port) - raise TTransportException(type=TTransportException.NOT_OPEN, message=message) - - def read(self, sz): - try: - buff = self.handle.recv(sz) - except socket.error, e: - if (e.args[0] == errno.ECONNRESET and - (sys.platform == 'darwin' or sys.platform.startswith('freebsd'))): - # freebsd and Mach don't follow POSIX semantic of recv - # and fail with ECONNRESET if peer performed shutdown. - # See corresponding comment and code in TSocket::read() - # in lib/cpp/src/transport/TSocket.cpp. - self.close() - # Trigger the check to raise the END_OF_FILE exception below. - buff = '' - else: - raise - if len(buff) == 0: - raise TTransportException(type=TTransportException.END_OF_FILE, message='TSocket read 0 bytes') - return buff - - def write(self, buff): - if not self.handle: - raise TTransportException(type=TTransportException.NOT_OPEN, message='Transport not open') - sent = 0 - have = len(buff) - while sent < have: - plus = self.handle.send(buff) - if plus == 0: - raise TTransportException(type=TTransportException.END_OF_FILE, message='TSocket sent 0 bytes') - sent += plus - buff = buff[plus:] - - def flush(self): - pass - -class TServerSocket(TSocketBase, TServerTransportBase): - """Socket implementation of TServerTransport base.""" - - def __init__(self, host=None, port=9090, unix_socket=None): - self.host = host - self.port = port - self._unix_socket = unix_socket - self.handle = None - - def listen(self): - res0 = self._resolveAddr() - for res in res0: - if res[0] is socket.AF_INET6 or res is res0[-1]: - break - - # We need remove the old unix socket if the file exists and - # nobody is listening on it. - if self._unix_socket: - tmp = socket.socket(res[0], res[1]) - try: - tmp.connect(res[4]) - except socket.error, err: - eno, message = err.args - if eno == errno.ECONNREFUSED: - os.unlink(res[4]) - - self.handle = socket.socket(res[0], res[1]) - self.handle.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - if hasattr(self.handle, 'settimeout'): - self.handle.settimeout(None) - self.handle.bind(res[4]) - self.handle.listen(128) - - def accept(self): - client, addr = self.handle.accept() - result = TSocket() - result.setHandle(client) - return result diff --git a/anknotes/thrift/transport/TTransport.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/transport/TTransport.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 9ffdc05..0000000 --- a/anknotes/thrift/transport/TTransport.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,331 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from cStringIO import StringIO -from struct import pack,unpack -from anknotes.thrift.Thrift import TException - -class TTransportException(TException): - - """Custom Transport Exception class""" - - UNKNOWN = 0 - NOT_OPEN = 1 - ALREADY_OPEN = 2 - TIMED_OUT = 3 - END_OF_FILE = 4 - - def __init__(self, type=UNKNOWN, message=None): - TException.__init__(self, message) - self.type = type - -class TTransportBase: - - """Base class for Thrift transport layer.""" - - def isOpen(self): - pass - - def open(self): - pass - - def close(self): - pass - - def read(self, sz): - pass - - def readAll(self, sz): - buff = '' - have = 0 - while (have < sz): - chunk = self.read(sz-have) - have += len(chunk) - buff += chunk - - if len(chunk) == 0: - raise EOFError() - - return buff - - def write(self, buf): - pass - - def flush(self): - pass - -# This class should be thought of as an interface. -class CReadableTransport: - """base class for transports that are readable from C""" - - # TODO(dreiss): Think about changing this interface to allow us to use - # a (Python, not c) StringIO instead, because it allows - # you to write after reading. - - # NOTE: This is a classic class, so properties will NOT work - # correctly for setting. - @property - def cstringio_buf(self): - """A cStringIO buffer that contains the current chunk we are reading.""" - pass - - def cstringio_refill(self, partialread, reqlen): - """Refills cstringio_buf. - - Returns the currently used buffer (which can but need not be the same as - the old cstringio_buf). partialread is what the C code has read from the - buffer, and should be inserted into the buffer before any more reads. The - return value must be a new, not borrowed reference. Something along the - lines of self._buf should be fine. - - If reqlen bytes can't be read, throw EOFError. - """ - pass - -class TServerTransportBase: - - """Base class for Thrift server transports.""" - - def listen(self): - pass - - def accept(self): - pass - - def close(self): - pass - -class TTransportFactoryBase: - - """Base class for a Transport Factory""" - - def getTransport(self, trans): - return trans - -class TBufferedTransportFactory: - - """Factory transport that builds buffered transports""" - - def getTransport(self, trans): - buffered = TBufferedTransport(trans) - return buffered - - -class TBufferedTransport(TTransportBase,CReadableTransport): - - """Class that wraps another transport and buffers its I/O. - - The implementation uses a (configurable) fixed-size read buffer - but buffers all writes until a flush is performed. - """ - - DEFAULT_BUFFER = 4096 - - def __init__(self, trans, rbuf_size = DEFAULT_BUFFER): - self.__trans = trans - self.__wbuf = StringIO() - self.__rbuf = StringIO("") - self.__rbuf_size = rbuf_size - - def isOpen(self): - return self.__trans.isOpen() - - def open(self): - return self.__trans.open() - - def close(self): - return self.__trans.close() - - def read(self, sz): - ret = self.__rbuf.read(sz) - if len(ret) != 0: - return ret - - self.__rbuf = StringIO(self.__trans.read(max(sz, self.__rbuf_size))) - return self.__rbuf.read(sz) - - def write(self, buf): - self.__wbuf.write(buf) - - def flush(self): - out = self.__wbuf.getvalue() - # reset wbuf before write/flush to preserve state on underlying failure - self.__wbuf = StringIO() - self.__trans.write(out) - self.__trans.flush() - - # Implement the CReadableTransport interface. - @property - def cstringio_buf(self): - return self.__rbuf - - def cstringio_refill(self, partialread, reqlen): - retstring = partialread - if reqlen < self.__rbuf_size: - # try to make a read of as much as we can. - retstring += self.__trans.read(self.__rbuf_size) - - # but make sure we do read reqlen bytes. - if len(retstring) < reqlen: - retstring += self.__trans.readAll(reqlen - len(retstring)) - - self.__rbuf = StringIO(retstring) - return self.__rbuf - -class TMemoryBuffer(TTransportBase, CReadableTransport): - """Wraps a cStringIO object as a TTransport. - - NOTE: Unlike the C++ version of this class, you cannot write to it - then immediately read from it. If you want to read from a - TMemoryBuffer, you must either pass a string to the constructor. - TODO(dreiss): Make this work like the C++ version. - """ - - def __init__(self, value=None): - """value -- a value to read from for stringio - - If value is set, this will be a transport for reading, - otherwise, it is for writing""" - if value is not None: - self._buffer = StringIO(value) - else: - self._buffer = StringIO() - - def isOpen(self): - return not self._buffer.closed - - def open(self): - pass - - def close(self): - self._buffer.close() - - def read(self, sz): - return self._buffer.read(sz) - - def write(self, buf): - self._buffer.write(buf) - - def flush(self): - pass - - def getvalue(self): - return self._buffer.getvalue() - - # Implement the CReadableTransport interface. - @property - def cstringio_buf(self): - return self._buffer - - def cstringio_refill(self, partialread, reqlen): - # only one shot at reading... - raise EOFError() - -class TFramedTransportFactory: - - """Factory transport that builds framed transports""" - - def getTransport(self, trans): - framed = TFramedTransport(trans) - return framed - - -class TFramedTransport(TTransportBase, CReadableTransport): - - """Class that wraps another transport and frames its I/O when writing.""" - - def __init__(self, trans,): - self.__trans = trans - self.__rbuf = StringIO() - self.__wbuf = StringIO() - - def isOpen(self): - return self.__trans.isOpen() - - def open(self): - return self.__trans.open() - - def close(self): - return self.__trans.close() - - def read(self, sz): - ret = self.__rbuf.read(sz) - if len(ret) != 0: - return ret - - self.readFrame() - return self.__rbuf.read(sz) - - def readFrame(self): - buff = self.__trans.readAll(4) - sz, = unpack('!i', buff) - self.__rbuf = StringIO(self.__trans.readAll(sz)) - - def write(self, buf): - self.__wbuf.write(buf) - - def flush(self): - wout = self.__wbuf.getvalue() - wsz = len(wout) - # reset wbuf before write/flush to preserve state on underlying failure - self.__wbuf = StringIO() - # N.B.: Doing this string concatenation is WAY cheaper than making - # two separate calls to the underlying socket object. Socket writes in - # Python turn out to be REALLY expensive, but it seems to do a pretty - # good job of managing string buffer operations without excessive copies - buf = pack("!i", wsz) + wout - self.__trans.write(buf) - self.__trans.flush() - - # Implement the CReadableTransport interface. - @property - def cstringio_buf(self): - return self.__rbuf - - def cstringio_refill(self, prefix, reqlen): - # self.__rbuf will already be empty here because fastbinary doesn't - # ask for a refill until the previous buffer is empty. Therefore, - # we can start reading new frames immediately. - while len(prefix) < reqlen: - self.readFrame() - prefix += self.__rbuf.getvalue() - self.__rbuf = StringIO(prefix) - return self.__rbuf - - -class TFileObjectTransport(TTransportBase): - """Wraps a file-like object to make it work as a Thrift transport.""" - - def __init__(self, fileobj): - self.fileobj = fileobj - - def isOpen(self): - return True - - def close(self): - self.fileobj.close() - - def read(self, sz): - return self.fileobj.read(sz) - - def write(self, buf): - self.fileobj.write(buf) - - def flush(self): - self.fileobj.flush() diff --git a/anknotes/thrift/transport/TTransport.py~HEAD b/anknotes/thrift/transport/TTransport.py~HEAD deleted file mode 100644 index 12e51a9..0000000 --- a/anknotes/thrift/transport/TTransport.py~HEAD +++ /dev/null @@ -1,331 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -from cStringIO import StringIO -from struct import pack,unpack -from thrift.Thrift import TException - -class TTransportException(TException): - - """Custom Transport Exception class""" - - UNKNOWN = 0 - NOT_OPEN = 1 - ALREADY_OPEN = 2 - TIMED_OUT = 3 - END_OF_FILE = 4 - - def __init__(self, type=UNKNOWN, message=None): - TException.__init__(self, message) - self.type = type - -class TTransportBase: - - """Base class for Thrift transport layer.""" - - def isOpen(self): - pass - - def open(self): - pass - - def close(self): - pass - - def read(self, sz): - pass - - def readAll(self, sz): - buff = '' - have = 0 - while (have < sz): - chunk = self.read(sz-have) - have += len(chunk) - buff += chunk - - if len(chunk) == 0: - raise EOFError() - - return buff - - def write(self, buf): - pass - - def flush(self): - pass - -# This class should be thought of as an interface. -class CReadableTransport: - """base class for transports that are readable from C""" - - # TODO(dreiss): Think about changing this interface to allow us to use - # a (Python, not c) StringIO instead, because it allows - # you to write after reading. - - # NOTE: This is a classic class, so properties will NOT work - # correctly for setting. - @property - def cstringio_buf(self): - """A cStringIO buffer that contains the current chunk we are reading.""" - pass - - def cstringio_refill(self, partialread, reqlen): - """Refills cstringio_buf. - - Returns the currently used buffer (which can but need not be the same as - the old cstringio_buf). partialread is what the C code has read from the - buffer, and should be inserted into the buffer before any more reads. The - return value must be a new, not borrowed reference. Something along the - lines of self._buf should be fine. - - If reqlen bytes can't be read, throw EOFError. - """ - pass - -class TServerTransportBase: - - """Base class for Thrift server transports.""" - - def listen(self): - pass - - def accept(self): - pass - - def close(self): - pass - -class TTransportFactoryBase: - - """Base class for a Transport Factory""" - - def getTransport(self, trans): - return trans - -class TBufferedTransportFactory: - - """Factory transport that builds buffered transports""" - - def getTransport(self, trans): - buffered = TBufferedTransport(trans) - return buffered - - -class TBufferedTransport(TTransportBase,CReadableTransport): - - """Class that wraps another transport and buffers its I/O. - - The implementation uses a (configurable) fixed-size read buffer - but buffers all writes until a flush is performed. - """ - - DEFAULT_BUFFER = 4096 - - def __init__(self, trans, rbuf_size = DEFAULT_BUFFER): - self.__trans = trans - self.__wbuf = StringIO() - self.__rbuf = StringIO("") - self.__rbuf_size = rbuf_size - - def isOpen(self): - return self.__trans.isOpen() - - def open(self): - return self.__trans.open() - - def close(self): - return self.__trans.close() - - def read(self, sz): - ret = self.__rbuf.read(sz) - if len(ret) != 0: - return ret - - self.__rbuf = StringIO(self.__trans.read(max(sz, self.__rbuf_size))) - return self.__rbuf.read(sz) - - def write(self, buf): - self.__wbuf.write(buf) - - def flush(self): - out = self.__wbuf.getvalue() - # reset wbuf before write/flush to preserve state on underlying failure - self.__wbuf = StringIO() - self.__trans.write(out) - self.__trans.flush() - - # Implement the CReadableTransport interface. - @property - def cstringio_buf(self): - return self.__rbuf - - def cstringio_refill(self, partialread, reqlen): - retstring = partialread - if reqlen < self.__rbuf_size: - # try to make a read of as much as we can. - retstring += self.__trans.read(self.__rbuf_size) - - # but make sure we do read reqlen bytes. - if len(retstring) < reqlen: - retstring += self.__trans.readAll(reqlen - len(retstring)) - - self.__rbuf = StringIO(retstring) - return self.__rbuf - -class TMemoryBuffer(TTransportBase, CReadableTransport): - """Wraps a cStringIO object as a TTransport. - - NOTE: Unlike the C++ version of this class, you cannot write to it - then immediately read from it. If you want to read from a - TMemoryBuffer, you must either pass a string to the constructor. - TODO(dreiss): Make this work like the C++ version. - """ - - def __init__(self, value=None): - """value -- a value to read from for stringio - - If value is set, this will be a transport for reading, - otherwise, it is for writing""" - if value is not None: - self._buffer = StringIO(value) - else: - self._buffer = StringIO() - - def isOpen(self): - return not self._buffer.closed - - def open(self): - pass - - def close(self): - self._buffer.close() - - def read(self, sz): - return self._buffer.read(sz) - - def write(self, buf): - self._buffer.write(buf) - - def flush(self): - pass - - def getvalue(self): - return self._buffer.getvalue() - - # Implement the CReadableTransport interface. - @property - def cstringio_buf(self): - return self._buffer - - def cstringio_refill(self, partialread, reqlen): - # only one shot at reading... - raise EOFError() - -class TFramedTransportFactory: - - """Factory transport that builds framed transports""" - - def getTransport(self, trans): - framed = TFramedTransport(trans) - return framed - - -class TFramedTransport(TTransportBase, CReadableTransport): - - """Class that wraps another transport and frames its I/O when writing.""" - - def __init__(self, trans,): - self.__trans = trans - self.__rbuf = StringIO() - self.__wbuf = StringIO() - - def isOpen(self): - return self.__trans.isOpen() - - def open(self): - return self.__trans.open() - - def close(self): - return self.__trans.close() - - def read(self, sz): - ret = self.__rbuf.read(sz) - if len(ret) != 0: - return ret - - self.readFrame() - return self.__rbuf.read(sz) - - def readFrame(self): - buff = self.__trans.readAll(4) - sz, = unpack('!i', buff) - self.__rbuf = StringIO(self.__trans.readAll(sz)) - - def write(self, buf): - self.__wbuf.write(buf) - - def flush(self): - wout = self.__wbuf.getvalue() - wsz = len(wout) - # reset wbuf before write/flush to preserve state on underlying failure - self.__wbuf = StringIO() - # N.B.: Doing this string concatenation is WAY cheaper than making - # two separate calls to the underlying socket object. Socket writes in - # Python turn out to be REALLY expensive, but it seems to do a pretty - # good job of managing string buffer operations without excessive copies - buf = pack("!i", wsz) + wout - self.__trans.write(buf) - self.__trans.flush() - - # Implement the CReadableTransport interface. - @property - def cstringio_buf(self): - return self.__rbuf - - def cstringio_refill(self, prefix, reqlen): - # self.__rbuf will already be empty here because fastbinary doesn't - # ask for a refill until the previous buffer is empty. Therefore, - # we can start reading new frames immediately. - while len(prefix) < reqlen: - self.readFrame() - prefix += self.__rbuf.getvalue() - self.__rbuf = StringIO(prefix) - return self.__rbuf - - -class TFileObjectTransport(TTransportBase): - """Wraps a file-like object to make it work as a Thrift transport.""" - - def __init__(self, fileobj): - self.fileobj = fileobj - - def isOpen(self): - return True - - def close(self): - self.fileobj.close() - - def read(self, sz): - return self.fileobj.read(sz) - - def write(self, buf): - self.fileobj.write(buf) - - def flush(self): - self.fileobj.flush() diff --git a/anknotes/thrift/transport/TTwisted.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/transport/TTwisted.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index b6dcb4e..0000000 --- a/anknotes/thrift/transport/TTwisted.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,219 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -from zope.interface import implements, Interface, Attribute -from twisted.internet.protocol import Protocol, ServerFactory, ClientFactory, \ - connectionDone -from twisted.internet import defer -from twisted.protocols import basic -from twisted.python import log -from twisted.web import server, resource, http - -from thrift.transport import TTransport -from cStringIO import StringIO - - -class TMessageSenderTransport(TTransport.TTransportBase): - - def __init__(self): - self.__wbuf = StringIO() - - def write(self, buf): - self.__wbuf.write(buf) - - def flush(self): - msg = self.__wbuf.getvalue() - self.__wbuf = StringIO() - self.sendMessage(msg) - - def sendMessage(self, message): - raise NotImplementedError - - -class TCallbackTransport(TMessageSenderTransport): - - def __init__(self, func): - TMessageSenderTransport.__init__(self) - self.func = func - - def sendMessage(self, message): - self.func(message) - - -class ThriftClientProtocol(basic.Int32StringReceiver): - - MAX_LENGTH = 2 ** 31 - 1 - - def __init__(self, client_class, iprot_factory, oprot_factory=None): - self._client_class = client_class - self._iprot_factory = iprot_factory - if oprot_factory is None: - self._oprot_factory = iprot_factory - else: - self._oprot_factory = oprot_factory - - self.recv_map = {} - self.started = defer.Deferred() - - def dispatch(self, msg): - self.sendString(msg) - - def connectionMade(self): - tmo = TCallbackTransport(self.dispatch) - self.client = self._client_class(tmo, self._oprot_factory) - self.started.callback(self.client) - - def connectionLost(self, reason=connectionDone): - for k,v in self.client._reqs.iteritems(): - tex = TTransport.TTransportException( - type=TTransport.TTransportException.END_OF_FILE, - message='Connection closed') - v.errback(tex) - - def stringReceived(self, frame): - tr = TTransport.TMemoryBuffer(frame) - iprot = self._iprot_factory.getProtocol(tr) - (fname, mtype, rseqid) = iprot.readMessageBegin() - - try: - method = self.recv_map[fname] - except KeyError: - method = getattr(self.client, 'recv_' + fname) - self.recv_map[fname] = method - - method(iprot, mtype, rseqid) - - -class ThriftServerProtocol(basic.Int32StringReceiver): - - MAX_LENGTH = 2 ** 31 - 1 - - def dispatch(self, msg): - self.sendString(msg) - - def processError(self, error): - self.transport.loseConnection() - - def processOk(self, _, tmo): - msg = tmo.getvalue() - - if len(msg) > 0: - self.dispatch(msg) - - def stringReceived(self, frame): - tmi = TTransport.TMemoryBuffer(frame) - tmo = TTransport.TMemoryBuffer() - - iprot = self.factory.iprot_factory.getProtocol(tmi) - oprot = self.factory.oprot_factory.getProtocol(tmo) - - d = self.factory.processor.process(iprot, oprot) - d.addCallbacks(self.processOk, self.processError, - callbackArgs=(tmo,)) - - -class IThriftServerFactory(Interface): - - processor = Attribute("Thrift processor") - - iprot_factory = Attribute("Input protocol factory") - - oprot_factory = Attribute("Output protocol factory") - - -class IThriftClientFactory(Interface): - - client_class = Attribute("Thrift client class") - - iprot_factory = Attribute("Input protocol factory") - - oprot_factory = Attribute("Output protocol factory") - - -class ThriftServerFactory(ServerFactory): - - implements(IThriftServerFactory) - - protocol = ThriftServerProtocol - - def __init__(self, processor, iprot_factory, oprot_factory=None): - self.processor = processor - self.iprot_factory = iprot_factory - if oprot_factory is None: - self.oprot_factory = iprot_factory - else: - self.oprot_factory = oprot_factory - - -class ThriftClientFactory(ClientFactory): - - implements(IThriftClientFactory) - - protocol = ThriftClientProtocol - - def __init__(self, client_class, iprot_factory, oprot_factory=None): - self.client_class = client_class - self.iprot_factory = iprot_factory - if oprot_factory is None: - self.oprot_factory = iprot_factory - else: - self.oprot_factory = oprot_factory - - def buildProtocol(self, addr): - p = self.protocol(self.client_class, self.iprot_factory, - self.oprot_factory) - p.factory = self - return p - - -class ThriftResource(resource.Resource): - - allowedMethods = ('POST',) - - def __init__(self, processor, inputProtocolFactory, - outputProtocolFactory=None): - resource.Resource.__init__(self) - self.inputProtocolFactory = inputProtocolFactory - if outputProtocolFactory is None: - self.outputProtocolFactory = inputProtocolFactory - else: - self.outputProtocolFactory = outputProtocolFactory - self.processor = processor - - def getChild(self, path, request): - return self - - def _cbProcess(self, _, request, tmo): - msg = tmo.getvalue() - request.setResponseCode(http.OK) - request.setHeader("content-type", "application/x-thrift") - request.write(msg) - request.finish() - - def render_POST(self, request): - request.content.seek(0, 0) - data = request.content.read() - tmi = TTransport.TMemoryBuffer(data) - tmo = TTransport.TMemoryBuffer() - - iprot = self.inputProtocolFactory.getProtocol(tmi) - oprot = self.outputProtocolFactory.getProtocol(tmo) - - d = self.processor.process(iprot, oprot) - d.addCallback(self._cbProcess, request, tmo) - return server.NOT_DONE_YET diff --git a/anknotes/thrift/transport/TTwisted.py~HEAD b/anknotes/thrift/transport/TTwisted.py~HEAD deleted file mode 100644 index b6dcb4e..0000000 --- a/anknotes/thrift/transport/TTwisted.py~HEAD +++ /dev/null @@ -1,219 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -from zope.interface import implements, Interface, Attribute -from twisted.internet.protocol import Protocol, ServerFactory, ClientFactory, \ - connectionDone -from twisted.internet import defer -from twisted.protocols import basic -from twisted.python import log -from twisted.web import server, resource, http - -from thrift.transport import TTransport -from cStringIO import StringIO - - -class TMessageSenderTransport(TTransport.TTransportBase): - - def __init__(self): - self.__wbuf = StringIO() - - def write(self, buf): - self.__wbuf.write(buf) - - def flush(self): - msg = self.__wbuf.getvalue() - self.__wbuf = StringIO() - self.sendMessage(msg) - - def sendMessage(self, message): - raise NotImplementedError - - -class TCallbackTransport(TMessageSenderTransport): - - def __init__(self, func): - TMessageSenderTransport.__init__(self) - self.func = func - - def sendMessage(self, message): - self.func(message) - - -class ThriftClientProtocol(basic.Int32StringReceiver): - - MAX_LENGTH = 2 ** 31 - 1 - - def __init__(self, client_class, iprot_factory, oprot_factory=None): - self._client_class = client_class - self._iprot_factory = iprot_factory - if oprot_factory is None: - self._oprot_factory = iprot_factory - else: - self._oprot_factory = oprot_factory - - self.recv_map = {} - self.started = defer.Deferred() - - def dispatch(self, msg): - self.sendString(msg) - - def connectionMade(self): - tmo = TCallbackTransport(self.dispatch) - self.client = self._client_class(tmo, self._oprot_factory) - self.started.callback(self.client) - - def connectionLost(self, reason=connectionDone): - for k,v in self.client._reqs.iteritems(): - tex = TTransport.TTransportException( - type=TTransport.TTransportException.END_OF_FILE, - message='Connection closed') - v.errback(tex) - - def stringReceived(self, frame): - tr = TTransport.TMemoryBuffer(frame) - iprot = self._iprot_factory.getProtocol(tr) - (fname, mtype, rseqid) = iprot.readMessageBegin() - - try: - method = self.recv_map[fname] - except KeyError: - method = getattr(self.client, 'recv_' + fname) - self.recv_map[fname] = method - - method(iprot, mtype, rseqid) - - -class ThriftServerProtocol(basic.Int32StringReceiver): - - MAX_LENGTH = 2 ** 31 - 1 - - def dispatch(self, msg): - self.sendString(msg) - - def processError(self, error): - self.transport.loseConnection() - - def processOk(self, _, tmo): - msg = tmo.getvalue() - - if len(msg) > 0: - self.dispatch(msg) - - def stringReceived(self, frame): - tmi = TTransport.TMemoryBuffer(frame) - tmo = TTransport.TMemoryBuffer() - - iprot = self.factory.iprot_factory.getProtocol(tmi) - oprot = self.factory.oprot_factory.getProtocol(tmo) - - d = self.factory.processor.process(iprot, oprot) - d.addCallbacks(self.processOk, self.processError, - callbackArgs=(tmo,)) - - -class IThriftServerFactory(Interface): - - processor = Attribute("Thrift processor") - - iprot_factory = Attribute("Input protocol factory") - - oprot_factory = Attribute("Output protocol factory") - - -class IThriftClientFactory(Interface): - - client_class = Attribute("Thrift client class") - - iprot_factory = Attribute("Input protocol factory") - - oprot_factory = Attribute("Output protocol factory") - - -class ThriftServerFactory(ServerFactory): - - implements(IThriftServerFactory) - - protocol = ThriftServerProtocol - - def __init__(self, processor, iprot_factory, oprot_factory=None): - self.processor = processor - self.iprot_factory = iprot_factory - if oprot_factory is None: - self.oprot_factory = iprot_factory - else: - self.oprot_factory = oprot_factory - - -class ThriftClientFactory(ClientFactory): - - implements(IThriftClientFactory) - - protocol = ThriftClientProtocol - - def __init__(self, client_class, iprot_factory, oprot_factory=None): - self.client_class = client_class - self.iprot_factory = iprot_factory - if oprot_factory is None: - self.oprot_factory = iprot_factory - else: - self.oprot_factory = oprot_factory - - def buildProtocol(self, addr): - p = self.protocol(self.client_class, self.iprot_factory, - self.oprot_factory) - p.factory = self - return p - - -class ThriftResource(resource.Resource): - - allowedMethods = ('POST',) - - def __init__(self, processor, inputProtocolFactory, - outputProtocolFactory=None): - resource.Resource.__init__(self) - self.inputProtocolFactory = inputProtocolFactory - if outputProtocolFactory is None: - self.outputProtocolFactory = inputProtocolFactory - else: - self.outputProtocolFactory = outputProtocolFactory - self.processor = processor - - def getChild(self, path, request): - return self - - def _cbProcess(self, _, request, tmo): - msg = tmo.getvalue() - request.setResponseCode(http.OK) - request.setHeader("content-type", "application/x-thrift") - request.write(msg) - request.finish() - - def render_POST(self, request): - request.content.seek(0, 0) - data = request.content.read() - tmi = TTransport.TMemoryBuffer(data) - tmo = TTransport.TMemoryBuffer() - - iprot = self.inputProtocolFactory.getProtocol(tmi) - oprot = self.outputProtocolFactory.getProtocol(tmo) - - d = self.processor.process(iprot, oprot) - d.addCallback(self._cbProcess, request, tmo) - return server.NOT_DONE_YET diff --git a/anknotes/thrift/transport/TZlibTransport.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/transport/TZlibTransport.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 784d4e1..0000000 --- a/anknotes/thrift/transport/TZlibTransport.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,261 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -''' -TZlibTransport provides a compressed transport and transport factory -class, using the python standard library zlib module to implement -data compression. -''' - -from __future__ import division -import zlib -from cStringIO import StringIO -from TTransport import TTransportBase, CReadableTransport - -class TZlibTransportFactory(object): - ''' - Factory transport that builds zlib compressed transports. - - This factory caches the last single client/transport that it was passed - and returns the same TZlibTransport object that was created. - - This caching means the TServer class will get the _same_ transport - object for both input and output transports from this factory. - (For non-threaded scenarios only, since the cache only holds one object) - - The purpose of this caching is to allocate only one TZlibTransport where - only one is really needed (since it must have separate read/write buffers), - and makes the statistics from getCompSavings() and getCompRatio() - easier to understand. - ''' - - # class scoped cache of last transport given and zlibtransport returned - _last_trans = None - _last_z = None - - def getTransport(self, trans, compresslevel=9): - '''Wrap a transport , trans, with the TZlibTransport - compressed transport class, returning a new - transport to the caller. - - @param compresslevel: The zlib compression level, ranging - from 0 (no compression) to 9 (best compression). Defaults to 9. - @type compresslevel: int - - This method returns a TZlibTransport which wraps the - passed C{trans} TTransport derived instance. - ''' - if trans == self._last_trans: - return self._last_z - ztrans = TZlibTransport(trans, compresslevel) - self._last_trans = trans - self._last_z = ztrans - return ztrans - - -class TZlibTransport(TTransportBase, CReadableTransport): - ''' - Class that wraps a transport with zlib, compressing writes - and decompresses reads, using the python standard - library zlib module. - ''' - - # Read buffer size for the python fastbinary C extension, - # the TBinaryProtocolAccelerated class. - DEFAULT_BUFFSIZE = 4096 - - def __init__(self, trans, compresslevel=9): - ''' - Create a new TZlibTransport, wrapping C{trans}, another - TTransport derived object. - - @param trans: A thrift transport object, i.e. a TSocket() object. - @type trans: TTransport - @param compresslevel: The zlib compression level, ranging - from 0 (no compression) to 9 (best compression). Default is 9. - @type compresslevel: int - ''' - self.__trans = trans - self.compresslevel = compresslevel - self.__rbuf = StringIO() - self.__wbuf = StringIO() - self._init_zlib() - self._init_stats() - - def _reinit_buffers(self): - ''' - Internal method to initialize/reset the internal StringIO objects - for read and write buffers. - ''' - self.__rbuf = StringIO() - self.__wbuf = StringIO() - - def _init_stats(self): - ''' - Internal method to reset the internal statistics counters - for compression ratios and bandwidth savings. - ''' - self.bytes_in = 0 - self.bytes_out = 0 - self.bytes_in_comp = 0 - self.bytes_out_comp = 0 - - def _init_zlib(self): - ''' - Internal method for setting up the zlib compression and - decompression objects. - ''' - self._zcomp_read = zlib.decompressobj() - self._zcomp_write = zlib.compressobj(self.compresslevel) - - def getCompRatio(self): - ''' - Get the current measured compression ratios (in,out) from - this transport. - - Returns a tuple of: - (inbound_compression_ratio, outbound_compression_ratio) - - The compression ratios are computed as: - compressed / uncompressed - - E.g., data that compresses by 10x will have a ratio of: 0.10 - and data that compresses to half of ts original size will - have a ratio of 0.5 - - None is returned if no bytes have yet been processed in - a particular direction. - ''' - r_percent, w_percent = (None, None) - if self.bytes_in > 0: - r_percent = self.bytes_in_comp / self.bytes_in - if self.bytes_out > 0: - w_percent = self.bytes_out_comp / self.bytes_out - return (r_percent, w_percent) - - def getCompSavings(self): - ''' - Get the current count of saved bytes due to data - compression. - - Returns a tuple of: - (inbound_saved_bytes, outbound_saved_bytes) - - Note: if compression is actually expanding your - data (only likely with very tiny thrift objects), then - the values returned will be negative. - ''' - r_saved = self.bytes_in - self.bytes_in_comp - w_saved = self.bytes_out - self.bytes_out_comp - return (r_saved, w_saved) - - def isOpen(self): - '''Return the underlying transport's open status''' - return self.__trans.isOpen() - - def open(self): - """Open the underlying transport""" - self._init_stats() - return self.__trans.open() - - def listen(self): - '''Invoke the underlying transport's listen() method''' - self.__trans.listen() - - def accept(self): - '''Accept connections on the underlying transport''' - return self.__trans.accept() - - def close(self): - '''Close the underlying transport,''' - self._reinit_buffers() - self._init_zlib() - return self.__trans.close() - - def read(self, sz): - ''' - Read up to sz bytes from the decompressed bytes buffer, and - read from the underlying transport if the decompression - buffer is empty. - ''' - ret = self.__rbuf.read(sz) - if len(ret) > 0: - return ret - # keep reading from transport until something comes back - while True: - if self.readComp(sz): - break - ret = self.__rbuf.read(sz) - return ret - - def readComp(self, sz): - ''' - Read compressed data from the underlying transport, then - decompress it and append it to the internal StringIO read buffer - ''' - zbuf = self.__trans.read(sz) - zbuf = self._zcomp_read.unconsumed_tail + zbuf - buf = self._zcomp_read.decompress(zbuf) - self.bytes_in += len(zbuf) - self.bytes_in_comp += len(buf) - old = self.__rbuf.read() - self.__rbuf = StringIO(old + buf) - if len(old) + len(buf) == 0: - return False - return True - - def write(self, buf): - ''' - Write some bytes, putting them into the internal write - buffer for eventual compression. - ''' - self.__wbuf.write(buf) - - def flush(self): - ''' - Flush any queued up data in the write buffer and ensure the - compression buffer is flushed out to the underlying transport - ''' - wout = self.__wbuf.getvalue() - if len(wout) > 0: - zbuf = self._zcomp_write.compress(wout) - self.bytes_out += len(wout) - self.bytes_out_comp += len(zbuf) - else: - zbuf = '' - ztail = self._zcomp_write.flush(zlib.Z_SYNC_FLUSH) - self.bytes_out_comp += len(ztail) - if (len(zbuf) + len(ztail)) > 0: - self.__wbuf = StringIO() - self.__trans.write(zbuf + ztail) - self.__trans.flush() - - @property - def cstringio_buf(self): - '''Implement the CReadableTransport interface''' - return self.__rbuf - - def cstringio_refill(self, partialread, reqlen): - '''Implement the CReadableTransport interface for refill''' - retstring = partialread - if reqlen < self.DEFAULT_BUFFSIZE: - retstring += self.read(self.DEFAULT_BUFFSIZE) - while len(retstring) < reqlen: - retstring += self.read(reqlen - len(retstring)) - self.__rbuf = StringIO(retstring) - return self.__rbuf diff --git a/anknotes/thrift/transport/TZlibTransport.py~HEAD b/anknotes/thrift/transport/TZlibTransport.py~HEAD deleted file mode 100644 index 784d4e1..0000000 --- a/anknotes/thrift/transport/TZlibTransport.py~HEAD +++ /dev/null @@ -1,261 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -''' -TZlibTransport provides a compressed transport and transport factory -class, using the python standard library zlib module to implement -data compression. -''' - -from __future__ import division -import zlib -from cStringIO import StringIO -from TTransport import TTransportBase, CReadableTransport - -class TZlibTransportFactory(object): - ''' - Factory transport that builds zlib compressed transports. - - This factory caches the last single client/transport that it was passed - and returns the same TZlibTransport object that was created. - - This caching means the TServer class will get the _same_ transport - object for both input and output transports from this factory. - (For non-threaded scenarios only, since the cache only holds one object) - - The purpose of this caching is to allocate only one TZlibTransport where - only one is really needed (since it must have separate read/write buffers), - and makes the statistics from getCompSavings() and getCompRatio() - easier to understand. - ''' - - # class scoped cache of last transport given and zlibtransport returned - _last_trans = None - _last_z = None - - def getTransport(self, trans, compresslevel=9): - '''Wrap a transport , trans, with the TZlibTransport - compressed transport class, returning a new - transport to the caller. - - @param compresslevel: The zlib compression level, ranging - from 0 (no compression) to 9 (best compression). Defaults to 9. - @type compresslevel: int - - This method returns a TZlibTransport which wraps the - passed C{trans} TTransport derived instance. - ''' - if trans == self._last_trans: - return self._last_z - ztrans = TZlibTransport(trans, compresslevel) - self._last_trans = trans - self._last_z = ztrans - return ztrans - - -class TZlibTransport(TTransportBase, CReadableTransport): - ''' - Class that wraps a transport with zlib, compressing writes - and decompresses reads, using the python standard - library zlib module. - ''' - - # Read buffer size for the python fastbinary C extension, - # the TBinaryProtocolAccelerated class. - DEFAULT_BUFFSIZE = 4096 - - def __init__(self, trans, compresslevel=9): - ''' - Create a new TZlibTransport, wrapping C{trans}, another - TTransport derived object. - - @param trans: A thrift transport object, i.e. a TSocket() object. - @type trans: TTransport - @param compresslevel: The zlib compression level, ranging - from 0 (no compression) to 9 (best compression). Default is 9. - @type compresslevel: int - ''' - self.__trans = trans - self.compresslevel = compresslevel - self.__rbuf = StringIO() - self.__wbuf = StringIO() - self._init_zlib() - self._init_stats() - - def _reinit_buffers(self): - ''' - Internal method to initialize/reset the internal StringIO objects - for read and write buffers. - ''' - self.__rbuf = StringIO() - self.__wbuf = StringIO() - - def _init_stats(self): - ''' - Internal method to reset the internal statistics counters - for compression ratios and bandwidth savings. - ''' - self.bytes_in = 0 - self.bytes_out = 0 - self.bytes_in_comp = 0 - self.bytes_out_comp = 0 - - def _init_zlib(self): - ''' - Internal method for setting up the zlib compression and - decompression objects. - ''' - self._zcomp_read = zlib.decompressobj() - self._zcomp_write = zlib.compressobj(self.compresslevel) - - def getCompRatio(self): - ''' - Get the current measured compression ratios (in,out) from - this transport. - - Returns a tuple of: - (inbound_compression_ratio, outbound_compression_ratio) - - The compression ratios are computed as: - compressed / uncompressed - - E.g., data that compresses by 10x will have a ratio of: 0.10 - and data that compresses to half of ts original size will - have a ratio of 0.5 - - None is returned if no bytes have yet been processed in - a particular direction. - ''' - r_percent, w_percent = (None, None) - if self.bytes_in > 0: - r_percent = self.bytes_in_comp / self.bytes_in - if self.bytes_out > 0: - w_percent = self.bytes_out_comp / self.bytes_out - return (r_percent, w_percent) - - def getCompSavings(self): - ''' - Get the current count of saved bytes due to data - compression. - - Returns a tuple of: - (inbound_saved_bytes, outbound_saved_bytes) - - Note: if compression is actually expanding your - data (only likely with very tiny thrift objects), then - the values returned will be negative. - ''' - r_saved = self.bytes_in - self.bytes_in_comp - w_saved = self.bytes_out - self.bytes_out_comp - return (r_saved, w_saved) - - def isOpen(self): - '''Return the underlying transport's open status''' - return self.__trans.isOpen() - - def open(self): - """Open the underlying transport""" - self._init_stats() - return self.__trans.open() - - def listen(self): - '''Invoke the underlying transport's listen() method''' - self.__trans.listen() - - def accept(self): - '''Accept connections on the underlying transport''' - return self.__trans.accept() - - def close(self): - '''Close the underlying transport,''' - self._reinit_buffers() - self._init_zlib() - return self.__trans.close() - - def read(self, sz): - ''' - Read up to sz bytes from the decompressed bytes buffer, and - read from the underlying transport if the decompression - buffer is empty. - ''' - ret = self.__rbuf.read(sz) - if len(ret) > 0: - return ret - # keep reading from transport until something comes back - while True: - if self.readComp(sz): - break - ret = self.__rbuf.read(sz) - return ret - - def readComp(self, sz): - ''' - Read compressed data from the underlying transport, then - decompress it and append it to the internal StringIO read buffer - ''' - zbuf = self.__trans.read(sz) - zbuf = self._zcomp_read.unconsumed_tail + zbuf - buf = self._zcomp_read.decompress(zbuf) - self.bytes_in += len(zbuf) - self.bytes_in_comp += len(buf) - old = self.__rbuf.read() - self.__rbuf = StringIO(old + buf) - if len(old) + len(buf) == 0: - return False - return True - - def write(self, buf): - ''' - Write some bytes, putting them into the internal write - buffer for eventual compression. - ''' - self.__wbuf.write(buf) - - def flush(self): - ''' - Flush any queued up data in the write buffer and ensure the - compression buffer is flushed out to the underlying transport - ''' - wout = self.__wbuf.getvalue() - if len(wout) > 0: - zbuf = self._zcomp_write.compress(wout) - self.bytes_out += len(wout) - self.bytes_out_comp += len(zbuf) - else: - zbuf = '' - ztail = self._zcomp_write.flush(zlib.Z_SYNC_FLUSH) - self.bytes_out_comp += len(ztail) - if (len(zbuf) + len(ztail)) > 0: - self.__wbuf = StringIO() - self.__trans.write(zbuf + ztail) - self.__trans.flush() - - @property - def cstringio_buf(self): - '''Implement the CReadableTransport interface''' - return self.__rbuf - - def cstringio_refill(self, partialread, reqlen): - '''Implement the CReadableTransport interface for refill''' - retstring = partialread - if reqlen < self.DEFAULT_BUFFSIZE: - retstring += self.read(self.DEFAULT_BUFFSIZE) - while len(retstring) < reqlen: - retstring += self.read(reqlen - len(retstring)) - self.__rbuf = StringIO(retstring) - return self.__rbuf diff --git a/anknotes/thrift/transport/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 b/anknotes/thrift/transport/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 deleted file mode 100644 index 46e54fe..0000000 --- a/anknotes/thrift/transport/__init__.py~155d40b1f21ee8336f1c8d81dbef09df4cb39236 +++ /dev/null @@ -1,20 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -__all__ = ['TTransport', 'TSocket', 'THttpClient','TZlibTransport'] diff --git a/anknotes/thrift/transport/__init__.py~HEAD b/anknotes/thrift/transport/__init__.py~HEAD deleted file mode 100644 index 46e54fe..0000000 --- a/anknotes/thrift/transport/__init__.py~HEAD +++ /dev/null @@ -1,20 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# - -__all__ = ['TTransport', 'TSocket', 'THttpClient','TZlibTransport'] diff --git a/anknotes/toc.py b/anknotes/toc.py new file mode 100644 index 0000000..2704d81 --- /dev/null +++ b/anknotes/toc.py @@ -0,0 +1,333 @@ +# -*- coding: utf-8 -*- +try: + from pysqlite2 import dbapi2 as sqlite +except ImportError: + from sqlite3 import dbapi2 as sqlite +from anknotes.constants import * +from anknotes.html import generate_evernote_link, generate_evernote_span +from anknotes.logging import log_dump +from anknotes.EvernoteNoteTitle import EvernoteNoteTitle, generateTOCTitle +from anknotes.EvernoteNotePrototype import EvernoteNotePrototype + + +def TOCNamePriority(title): + for index, value in enumerate( + ['Summary', 'Definition', 'Classification', 'Types', 'Presentation', 'Organ Involvement', 'Age of Onset', + 'Si/Sx', 'Sx', 'Sign', 'MCC\'s', 'MCC', 'Inheritance', 'Incidence', 'Prognosis', 'Mechanism', 'MOA', + 'Pathophysiology', 'Indications', 'Examples', 'Cause', 'Causes', 'Causative Organisms', 'Risk Factors', + 'Complication', 'Complications', 'Side Effects', 'Drug S/E', 'Associated Conditions', 'A/w', 'Dx', + 'Physical Exam', 'Labs', 'Hemodynamic Parameters', 'Lab Findings', 'Imaging', 'Screening Test', + 'Confirmatory Test']): + if title == value: return -1, index + for index, value in enumerate(['Management', 'Work Up', 'Tx']): + if title == value: return 1, index + return 0, 0 + + +def TOCNameSort(title1, title2): + priority1 = TOCNamePriority(title1) + priority2 = TOCNamePriority(title2) + # Lower value for item 1 = item 1 placed BEFORE item 2 + if priority1[0] != priority2[0]: return priority1[0] - priority2[0] + if priority1[1] != priority2[1]: return priority1[1] - priority2[1] + return cmp(title1, title2) + + +def TOCSort(hash1, hash2): + lvl1 = hash1.Level + lvl2 = hash2.Level + names1 = hash1.TitleParts + names2 = hash2.TitleParts + for i in range(0, min(lvl1, lvl2)): + name1 = names1[i] + name2 = names2[i] + if name1 != name2: return TOCNameSort(name1, name2) + # Lower value for item 1 = item 1 placed BEFORE item 2 + return lvl1 - lvl2 + + +class TOCHierarchyClass: + Title = None + """:type : EvernoteNoteTitle""" + Note = None + """:type : EvernoteNotePrototype.EvernoteNotePrototype""" + Outline = None + """:type : TOCHierarchyClass""" + Number = 1 + Children = [] + """:type : list[TOCHierarchyClass]""" + Parent = None + """:type : TOCHierarchyClass""" + __isSorted__ = False + + @staticmethod + def TOCItemSort(tocHierarchy1, tocHierarchy2): + lvl1 = tocHierarchy1.Level + lvl2 = tocHierarchy2.Level + names1 = tocHierarchy1.TitleParts + names2 = tocHierarchy2.TitleParts + for i in range(0, min(lvl1, lvl2)): + name1 = names1[i] + name2 = names2[i] + if name1 != name2: return TOCNameSort(name1, name2) + # Lower value for item 1 = item 1 placed BEFORE item 2 + return lvl1 - lvl2 + + @property + def IsOutline(self): + if not self.Note: return False + return EVERNOTE.TAG.OUTLINE in self.Note.Tags + + def sortIfNeeded(self): + if self.__isSorted__: return + self.sortChildren() + + @property + def Level(self): + return self.Title.Level + + @property + def ChildrenCount(self): + return len(self.Children) + + @property + def TitleParts(self): + return self.Title.TitleParts + + def addNote(self, note): + tocHierarchy = TOCHierarchyClass(note=note) + self.addHierarchy(tocHierarchy) + + def getChildIndex(self, tocChildHierarchy): + if not tocChildHierarchy in self.Children: return -1 + self.sortIfNeeded() + return self.Children.index(tocChildHierarchy) + + @property + def ListPrefix(self): + index = self.Index + isSingleItem = self.IsSingleItem + if isSingleItem is 0: return "" + if isSingleItem is 1: return "*" + return str(index) + "." + + @property + def IsSingleItem(self): + index = self.Index + if index is 0: return 0 + if index is 1 and len(self.Parent.Children) is 1: + return 1 + return -1 + + @property + def Index(self): + if not self.Parent: return 0 + return self.Parent.getChildIndex(self) + 1 + + def addTitle(self, title): + self.addHierarchy(TOCHierarchyClass(title)) + + def addHierarchy(self, tocHierarchy): + tocNewTitle = tocHierarchy.Title + tocNewLevel = tocNewTitle.Level + selfLevel = self.Title.Level + tocTestBase = tocHierarchy.Title.FullTitle.replace(self.Title.FullTitle, '') + if tocTestBase[:2] == ': ': + tocTestBase = tocTestBase[2:] + + print " \nAdd Hierarchy: %-70s --> %-40s\n-------------------------------------" % ( + self.Title.FullTitle, tocTestBase) + + if selfLevel > tocHierarchy.Title.Level: + print "New Title Level is Below current level" + return False + + selfTOCTitle = self.Title.TOCTitle + tocSelfSibling = tocNewTitle.Parents(self.Title.Level) + + if tocSelfSibling.TOCTitle != selfTOCTitle: + print "New Title doesn't match current path" + return False + + if tocNewLevel is self.Title.Level: + if tocHierarchy.IsOutline: + tocHierarchy.Parent = self + self.Outline = tocHierarchy + print "SUCCESS: Outline added" + return True + print "New Title Level is current level, but New Title is not Outline" + return False + + tocNewSelfChild = tocNewTitle.Parents(self.Title.Level + 1) + tocNewSelfChildTOCName = tocNewSelfChild.TOCName + isDirectChild = (tocHierarchy.Level == self.Level + 1) + if isDirectChild: + tocNewChildNamesTitle = "N/A" + print "New Title is a direct child of the current title" + else: + tocNewChildNamesTitle = tocHierarchy.Title.Names(self.Title.Level + 1).FullTitle + print "New Title is a Grandchild or deeper of the current title " + + for tocChild in self.Children: + assert (isinstance(tocChild, TOCHierarchyClass)) + if tocChild.Title.TOCName == tocNewSelfChildTOCName: + print "%-60s Child %-20s Match Succeeded for %s." % ( + self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + success = tocChild.addHierarchy(tocHierarchy) + if success: + return True + print "%-60s Child %-20s Match Succeeded for %s: However, unable to add to matched child" % ( + self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + print "%-60s Child %-20s Search failed for %s" % ( + self.Title.FullTitle + ':', tocNewSelfChild.Name, tocNewChildNamesTitle) + + newChild = tocHierarchy if isDirectChild else TOCHierarchyClass(tocNewSelfChild) + newChild.parent = self + if isDirectChild: + print "%-60s Child %-20s Created Direct Child for %s." % ( + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + success = True + else: + print "%-60s Child %-20s Created Title-Only Child for %-40ss." % ( + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + success = newChild.addHierarchy(tocHierarchy) + print "%-60s Child %-20s Created Title-Only Child for %-40s: Match %s." % ( + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle, + "succeeded" if success else "failed") + self.__isSorted__ = False + self.Children.append(newChild) + + print "%-60s Child %-20s Appended Child for %s. Operation was an overall %s." % ( + self.Title.FullTitle + ':', newChild.Title.Name + ':', tocNewChildNamesTitle, + "success" if success else "failure") + return success + + def sortChildren(self): + self.Children = sorted(self.Children, self.TOCItemSort) + self.__isSorted__ = True + + def __strsingle__(self, fullTitle=False): + selfTitleStr = self.Title.FullTitle + selfNameStr = self.Title.Name + selfLevel = self.Title.Level + selfDepth = self.Title.Depth + selfListPrefix = self.ListPrefix + strr = '' + if selfLevel == 1: + strr += ' [%d] ' % len(self.Children) + else: + if len(self.Children): + strr += ' [%d:%2d] ' % (selfDepth, len(self.Children)) + else: + strr += ' [%d] ' % selfDepth + strr += ' ' * (selfDepth * 3) + strr += ' %s ' % selfListPrefix + + strr += '%-60s %s' % (selfTitleStr if fullTitle else selfNameStr, '' if self.Note else '(No Note)') + return strr + + def __str__(self, fullTitle=True, fullChildrenTitles=False): + self.sortIfNeeded() + lst = [self.__strsingle__(fullTitle)] + for child in self.Children: + lst.append(child.__str__(fullChildrenTitles, fullChildrenTitles)) + return '\n'.join(lst) + + def GetOrderedListItem(self, title=None): + if not title: title = self.Title.Name + selfTitleStr = title + selfLevel = self.Title.Level + selfDepth = self.Title.Depth + if selfLevel == 1: + guid = 'guid-pending' + if self.Note: guid = self.Note.Guid + link = generate_evernote_link(guid, generateTOCTitle(selfTitleStr), 'TOC') + if self.Outline: + link += ' ' + generate_evernote_link(self.Outline.Note.Guid, + '(<span style="color: rgb(255, 255, 255);">O</span>)', 'Outline', + escape=False) + return link + if self.Note: + return self.Note.generateLevelLink(selfDepth) + else: + return generate_evernote_span(selfTitleStr, 'Levels', selfDepth) + + def GetOrderedList(self, title=None): + self.sortIfNeeded() + lst = [] + header = (self.GetOrderedListItem(title)) + if self.ChildrenCount > 0: + for child in self.Children: + lst.append(child.GetOrderedList()) + childHTML = '\n'.join(lst) + else: + childHTML = '' + if childHTML: + tag = 'ol' if self.ChildrenCount > 1 else 'ul' + base = '<%s>\r\n%s\r\n</%s>\r\n' + # base = base.encode('utf8') + # tag = tag.encode('utf8') + # childHTML = childHTML.encode('utf8') + childHTML = base % (tag, childHTML, tag) + + if self.Level is 1: + base = '<div> %s </div>\r\n %s \r\n' + # base = base.encode('utf8') + # childHTML = childHTML.encode('utf8') + # header = header.encode('utf8') + base = base % (header, childHTML) + return base + base = '<li> %s \r\n %s \r\n</li> \r\n' + # base = base.encode('utf8') + # header = header.encode('utf8') + # childHTML = childHTML.encode('utf8') + base = base % (header, childHTML) + return base + + def __reprsingle__(self, fullTitle=True): + selfTitleStr = self.Title.FullTitle + selfNameStr = self.Title.Name + # selfLevel = self.title.Level + # selfDepth = self.title.Depth + selfListPrefix = self.ListPrefix + strr = "<%s:%s[%d] %s%s>" % ( + self.__class__.__name__, selfListPrefix, len(self.Children), selfTitleStr if fullTitle else selfNameStr, + '' if self.Note else ' *') + return strr + + def __repr__(self, fullTitle=True, fullChildrenTitles=False): + self.sortIfNeeded() + lst = [self.__reprsingle__(fullTitle)] + for child in self.Children: + lst.append(child.__repr__(fullChildrenTitles, fullChildrenTitles)) + return '\n'.join(lst) + + def __init__(self, title=None, note=None, number=1): + """ + :type title: EvernoteNoteTitle + :type note: EvernoteNotePrototype.EvernoteNotePrototype + """ + assert note or title + self.Outline = None + if note: + if (isinstance(note, sqlite.Row)): + note = EvernoteNotePrototype(db_note=note) + + self.Note = note + self.Title = EvernoteNoteTitle(note) + else: + self.Title = EvernoteNoteTitle(title) + self.Note = None + self.Number = number + self.Children = [] + self.__isSorted__ = False + + # + # tocTest = TOCHierarchyClass("My Root Title") + # tocTest.addTitle("My Root Title: Somebody") + # tocTest.addTitle("My Root Title: Somebody: Else") + # tocTest.addTitle("My Root Title: Someone") + # tocTest.addTitle("My Root Title: Someone: Else") + # tocTest.addTitle("My Root Title: Someone: Else: Entirely") + # tocTest.addTitle("My Root Title: Z This: HasNo: Direct Parent") + # pass diff --git a/anknotes/version.py b/anknotes/version.py index 0fb5b6e..2bdceaa 100644 --- a/anknotes/version.py +++ b/anknotes/version.py @@ -26,9 +26,11 @@ of the same class, thus must follow the same rules) """ -import string, re +import string +import re from types import StringType + class Version: """Abstract base class for version numbering classes. Just provides constructor (__init__) and reproducer (__repr__), because those From d2d8740c675b7d405e1701da3418ee8963d3db3d Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 04:02:39 -0400 Subject: [PATCH 11/70] Fix api check on first load; Exclude developer automation files from repo --- .gitignore | 8 +++----- anknotes/extra/dev/Restart Anki - How to use.txt | 1 + anknotes/extra/dev/Restart Anki.lnk | Bin 2589 -> 0 bytes anknotes/extra/dev/anknotes.developer | 0 anknotes/extra/dev/anknotes.developer.automate | 0 anknotes/extra/dev/invisible.vbs | 1 + anknotes/logging.py | 4 +++- 7 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 anknotes/extra/dev/Restart Anki - How to use.txt delete mode 100644 anknotes/extra/dev/Restart Anki.lnk delete mode 100644 anknotes/extra/dev/anknotes.developer delete mode 100644 anknotes/extra/dev/anknotes.developer.automate create mode 100644 anknotes/extra/dev/invisible.vbs diff --git a/.gitignore b/.gitignore index 6ef1230..754c185 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,8 @@ anknotes/extra/logs/ -anknotes/extra/powergrep/ -anknotes/extra/local/ +anknotes/extra/dev/Restart Anki.lnk +anknotes/extra/dev/anknotes.developer +anknotes/extra/dev/anknotes.developer.automate anknotes/extra/user/ -anknotes/extra/anki_master -autopep8 -noteTest.py *.bk ################# diff --git a/anknotes/extra/dev/Restart Anki - How to use.txt b/anknotes/extra/dev/Restart Anki - How to use.txt new file mode 100644 index 0000000..1ed47f9 --- /dev/null +++ b/anknotes/extra/dev/Restart Anki - How to use.txt @@ -0,0 +1 @@ +Create a link that points to invisible.vbs with restart_anki.bat as its argument. Use that link to quickly restart anki while debugging \ No newline at end of file diff --git a/anknotes/extra/dev/Restart Anki.lnk b/anknotes/extra/dev/Restart Anki.lnk deleted file mode 100644 index d6508287ac1da6f74275e40283ce525ebf91fbe1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2589 zcmcguYfM~K5FQFGMF>K9gc{nSjbsUSSzcwAv}R%HUaYkc9`@mFV9Ty$1$LX=EiR3z zv`SL2R1<tulPa24q7j=KrHNXDX&Pf}8lp*k#+o+$Q*B72wZ_=s_nmuzh1B4$p1U)5 z&YW|;Gc#xA+}$K1ahZ$2MaC5u_m8qv@*rn9E${YCPQ;C6x6FAvF~${fpxD+IpDC;A zf2o_uyISc_d+WQUQfRXsT`ongN<DU-DMzJPNjKer@^onq`$A#yN|GeV<YzbCSxMAe zF=I^4T5PTosZ>%=I9Hk_EItW=i#WmC(d)!hDkWkC7Uc(I4SL052bE%8rff9#Ez(|f zq)An`A7jR@f4VE*r<S`$_i~$fvY6I?F*UAAy2TIOGI`cS%GY|V=~7eYYJOzHJ~-7G zgU2!A?VpENtx!)Ncu{I3C~dMwx?p`kBLB;q3aJnq5OQSmj5pitWl|OBfN7O8QT#l) zNZV%T{;ij|VIo;f^L{lXRoJydd6X$lCR(AE<tr19*BcBVxDU?Yok`{h@vdGb%#s)^ zih=Dz;z8uS@F}1?$(HRVX0~=PoR;7TbOeGCA5g1JRO(4YJ#&gl<phbOhzu)1nr}pb zN-G1vjUY}u@f@i)k*%6#*e1ScB;=8GCaOgn8Kj=5E1MR9&$soD!)JjM1zrf~%#258 zV!6X%6Brrs2X?_4BReeGn}bE#4wW4%6yj`7VmaEz3t4f7brHlfAiJQ;#=As$#I<?S zu%WT3s$pNTwYsWFq`|~4(sai(_O;gASxfAY@;m}-L$JEVjENt)z7ui;yXAs<%r_&+ z{u&Tl6qF8PP9M&-{Fa8dpfpwG>8Jy}CfK&8%NK}*b>@+79KSw&V!8^jZiYn<Av~T? zqy@u%>rM}LUj{>Tn8xGzZzCPzLW1fs#j7I)SyK9KN`YfWdg^d-HnG^7n-F@;Vd~i5 zu);WoLLhU`hfrunV(PGhA@kvog0R^ydm(!<ryji^v`mUFJO%>wJ(%CF)?1-%Lz%ZC zf(YmER)!KS90~b6U4~5l@j2h*a|gS%{XgB>eWm972jP^K)Skz#q`&c0q~OHwV*?9~ z^N5gS!%VczARcEj`V+5xbL;ulC)}eScJ|fA`@i7WIS>+OI8koEkAl@+SqaT-OO#Yu zi|z}^3zr#%AbjwsViKf&IYv(&`TObFHS_z6%{za&IfW@UO(l>M%)T+)D}tZ&_8Kp6 zh%!DtjyDIU)K8|SD1q=$1@;Vrngm~tLtorj-+0Y-`UjWvYa@S6WE=}WeqTTIUXZK; ztpm+H?mV&Ffg~%S0cbRaMqV1&>i%?--Sx>o8AJI;y3R8X5;%cIvIrZ&i$Uceev&Aa z;cZ9Hp)PbAbQS2ADt$4|i=$lSfW{8uW5=^rX>Irj7#~sYa{xp`5|?aAc{%=P*HO>q zd%wL{pFdsnOdS<sDtK&j6ufliD3tA7!p0@)x{Jk7H!9ch{Mfz((E{7``%g`3_oN-a zzNq!k>%YFo%({yc7z#oybV+b-7U*sYPO@>A`VT@SJ`BdFlLDFpG!y9zbhfCf<wxjA zWOp7uHLUeERBxWRG1iG%XCqoF1t)0wZw~usL3HHpC)zCQU-~-zqYE-#_kGU9JJ74L zq9YH3?)+W0@C^&IRAX4^#L4Wgn$f}CwIj7o>#_CGLo1+f=Zsp==)fyUl%-ekIqYAc Ca_T|= diff --git a/anknotes/extra/dev/anknotes.developer b/anknotes/extra/dev/anknotes.developer deleted file mode 100644 index e69de29..0000000 diff --git a/anknotes/extra/dev/anknotes.developer.automate b/anknotes/extra/dev/anknotes.developer.automate deleted file mode 100644 index e69de29..0000000 diff --git a/anknotes/extra/dev/invisible.vbs b/anknotes/extra/dev/invisible.vbs new file mode 100644 index 0000000..99c3552 --- /dev/null +++ b/anknotes/extra/dev/invisible.vbs @@ -0,0 +1 @@ +CreateObject("Wscript.Shell").Run """" & WScript.Arguments(0) & """", 0, True \ No newline at end of file diff --git a/anknotes/logging.py b/anknotes/logging.py index 2600f08..b301b47 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -401,7 +401,9 @@ def log_api(method, content='', **kwargs): def get_api_call_count(): - api_log = file(get_log_full_path('api'), 'r').read().splitlines() + path = get_log_full_path('api') + if not os.path.exists(path): return 0 + api_log = file(path, 'r').read().splitlines() count = 1 for i in range(len(api_log), 0, -1): call = api_log[i - 1] From edc851a44e9819c94fae36b2162912417f96cf08 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 04:08:49 -0400 Subject: [PATCH 12/70] Fix api check on first load; Exclude developer automation files from repo --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a3d86d8..0b38067 100644 --- a/README.md +++ b/README.md @@ -88,21 +88,21 @@ An Anki plug-in for downloading Evernote notes to Anki directly from Anki. In ad - Enable Auto Import and Pagination as per above, and then modify `constants.py`, setting `PAGING_RESTART_WHEN_COMPLETE` to `True` #### Note Processing Features -1. Fix [Evernote Note Links] (https://dev.evernote.com/doc/articles/note_links.php) so that they can be opened in Anki +1. Fix [Evernote Note Links] (https://dev.evernote.com/doc/articles/note_links.php) so that they can be opened in Anki <a id='post-process-links' /> - Convert "New Style" Evernote web links to "Classic" Evernote in-app links so that any note links open directly in Evernote - Convert all Evernote links to use two forward slashes instead of three to get around an Anki bug -1. Automatically embed images +1. Automatically embed images <a id='post-process-images' /> - This is a workaround since Anki cannot import Evernote resources such as embedded images, PDF files, sounds, etc - Anknotes will convert any of the following to embedded, linkable images: - Any HTML Dropbox sharing link to an image `(https://www.dropbox.com/s/...)` - Any Dropbox plain-text to an image (same as above, but plain-text links must end with `?dl=0` or `?dl=1`) - Any HTML link with Link Text beginning with "Image Link", e.g.: `<a href='http://www.foo.com/bar'>Image Link #1</a>` -1. Occlude (hide) certain text on fronts of Anki cards +1. Occlude (hide) certain text on fronts of Anki cards <a id='post-process-occlude' /> - Useful for displaying additional information but ensuring it only shows on backs of cards - Anknotes converts any of the following to special text that will display in grey color, and only on the backs of cards: - Any text with white foreground - Any text within two brackets, such as `<<Hide Me>>` -1. Automatically generate [Cloze fields] (http://ankisrs.net/docs/manual.html#cloze) +1. Automatically generate [Cloze fields] (http://ankisrs.net/docs/manual.html#cloze) <a id='post-process-cloze' /> - Any text with a single curly bracket will be converted into a cloze field - E.g., two cloze fields are generated from: The central nervous system is made up of the `{brain}` and `{spinal cord}` - If you want to generate a single cloze field (not increment the field #), insert a pound character `('#')` after the first curly bracket: From 410e2736f53004e5761ea6fd076ab323958c6870 Mon Sep 17 00:00:00 2001 From: Fritz Otlinghaus <fritz@otlinghaus.it> Date: Wed, 23 Sep 2015 14:57:29 +0200 Subject: [PATCH 13/70] fixed some startup problems --- anknotes/Anki.py | 1 + anknotes/ankEvernote.py | 12 ++++++++---- anknotes/extra/ancillary/FrontTemplate.htm | 4 ++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/anknotes/Anki.py b/anknotes/Anki.py index ee63d36..f164efc 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -92,6 +92,7 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang content = ankiNote.Content if isinstance(content, str): content = unicode(content, 'utf-8') + print(FIELDS.EVERNOTE_GUID_PREFIX + ankiNote.Guid) anki_field_info = { FIELDS.TITLE: title, FIELDS.CONTENT: content, diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index fe0f18e..2db7f2c 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -19,6 +19,8 @@ from anknotes.shared import * from anknotes.error import * +from anknotes.evernote.api.client import EvernoteClient + if not eTreeImported: ### Anknotes Class Imports from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher @@ -27,11 +29,13 @@ ### Evernote Imports from anknotes.evernote.edam.type.ttypes import Note as EvernoteNote from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException - from anknotes.evernote.api.client import EvernoteClient - try: - from aqt.utils import openLink, getText, showInfo - except: pass + +try: + from aqt.utils import openLink, getText, showInfo +except: + pass + ### Anki Imports diff --git a/anknotes/extra/ancillary/FrontTemplate.htm b/anknotes/extra/ancillary/FrontTemplate.htm index 0631843..0f26894 100644 --- a/anknotes/extra/ancillary/FrontTemplate.htm +++ b/anknotes/extra/ancillary/FrontTemplate.htm @@ -86,8 +86,8 @@ <script> evernote_guid_prefix = '%(Evernote GUID Prefix)s' - evernote_uid = '%(Evernote UID)s' - evernote_shard = '%(Evernote shard)s' + evernote_uid = '%(Evernote GUID)s' + function generateEvernoteLink(guid_field) { guid = guid_field.replace(evernote_guid_prefix, '') en_link = 'evernote://view/'+evernote_uid+'/'+evernote_shard+'/'+guid+'/'+guid+'/' From d83a7bcfdcf9fc386e725f56b0f33192891dd603 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 09:46:16 -0400 Subject: [PATCH 14/70] Rebuild template after obtaining Evernote uid, shard --- anknotes/Anki.py | 1 - anknotes/AnkiNotePrototype.py | 9 +++------ anknotes/ankEvernote.py | 3 +-- anknotes/extra/ancillary/FrontTemplate.htm | 4 ++-- anknotes/logging.py | 4 ++++ 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/anknotes/Anki.py b/anknotes/Anki.py index f164efc..ee63d36 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -92,7 +92,6 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang content = ankiNote.Content if isinstance(content, str): content = unicode(content, 'utf-8') - print(FIELDS.EVERNOTE_GUID_PREFIX + ankiNote.Guid) anki_field_info = { FIELDS.TITLE: title, FIELDS.CONTENT: content, diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 8f0451e..b43d5ea 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -213,8 +213,8 @@ def step_6_process_see_also_links(): if not see_also_match: if self.Fields[FIELDS.CONTENT].find("See Also") > -1: - raise ValueError - log("No See Also Content in " + self.Title.FullTitle + " \n" + self.Fields[FIELDS.CONTENT]) + log("No See Also Content Found, but phrase 'See Also' exists in " + self.Title.FullTitle + " \n" + self.Fields[FIELDS.CONTENT]) + raise ValueError return self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group(0), see_also_match.group('Suffix')) @@ -229,9 +229,7 @@ def step_6_process_see_also_links(): self.Fields[FIELDS.SEE_ALSO] += see_also if self.light_processing: self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group('Suffix'), self.Fields[FIELDS.SEE_ALSO] + see_also_match.group('Suffix')) - return - log_blank(); - log("Found see also match for %s\nContent: %s\n.\nSee Also: %s" % (self.Title.FullTitle, self.Fields[FIELDS.CONTENT], self.Fields[FIELDS.SEE_ALSO][:10])) + return self.process_note_see_also() if not FIELDS.CONTENT in self.Fields: return @@ -369,7 +367,6 @@ def update_note_fields(self): # log_dump({'self.note.fields': self.note.fields, 'self.note._model.flds': self.note._model['flds']}, "- > UPDATE_NOTE → anki.notes.Note: _model: flds") field_updates = [] fields_updated = {} - log_blank(); for fld in self.note._model['flds']: if FIELDS.EVERNOTE_GUID in fld.get('name'): self.OriginalGuid = self.note.fields[fld.get('ord')].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 2db7f2c..e6379c3 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -19,8 +19,6 @@ from anknotes.shared import * from anknotes.error import * -from anknotes.evernote.api.client import EvernoteClient - if not eTreeImported: ### Anknotes Class Imports from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher @@ -29,6 +27,7 @@ ### Evernote Imports from anknotes.evernote.edam.type.ttypes import Note as EvernoteNote from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException + from anknotes.evernote.api.client import EvernoteClient try: diff --git a/anknotes/extra/ancillary/FrontTemplate.htm b/anknotes/extra/ancillary/FrontTemplate.htm index 0f26894..0631843 100644 --- a/anknotes/extra/ancillary/FrontTemplate.htm +++ b/anknotes/extra/ancillary/FrontTemplate.htm @@ -86,8 +86,8 @@ <script> evernote_guid_prefix = '%(Evernote GUID Prefix)s' - evernote_uid = '%(Evernote GUID)s' - + evernote_uid = '%(Evernote UID)s' + evernote_shard = '%(Evernote shard)s' function generateEvernoteLink(guid_field) { guid = guid_field.replace(evernote_guid_prefix, '') en_link = 'evernote://view/'+evernote_uid+'/'+evernote_shard+'/'+guid+'/'+guid+'/' diff --git a/anknotes/logging.py b/anknotes/logging.py index b301b47..1f8149e 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -163,6 +163,10 @@ def wrap_filename(self, filename=None): filename = os.path.join(self.base_path, filename if filename else '') return filename + def dump(self, filename=None, *args, **kwargs): + filename = self.wrap_filename(filename) + log_dump(filename=filename, *args, **kwargs) + def blank(self, filename=None, *args, **kwargs): filename = self.wrap_filename(filename) log_blank(filename=filename, *args, **kwargs) From 6079aaaa72e54cc9154043c1bc8166aa41e58fe3 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 09:54:17 -0400 Subject: [PATCH 15/70] The Anki/Evernote imports actually need to be conditional, because this file is also used by the external command line utility to validate notes. Since that utility doesn't operate in the Anki environment, trying to import these modules will result in an import error. I changed the conditional to import if it's in Anki environment. --- anknotes/ankEvernote.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index e6379c3..1ff2702 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -6,10 +6,16 @@ try: from lxml import etree - eTreeImported = True except: eTreeImported = False + +try: + from aqt.utils import openLink, getText, showInfo + inAnki = True +except: + inAnki = False + try: from pysqlite2 import dbapi2 as sqlite except ImportError: @@ -19,7 +25,7 @@ from anknotes.shared import * from anknotes.error import * -if not eTreeImported: +if inAnki: ### Anknotes Class Imports from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher from anknotes.EvernoteNotePrototype import EvernoteNotePrototype @@ -30,12 +36,6 @@ from anknotes.evernote.api.client import EvernoteClient -try: - from aqt.utils import openLink, getText, showInfo -except: - pass - - ### Anki Imports # import anki From da89852527d3933bc5104baadc97630d6c6f34c8 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 10:19:30 -0400 Subject: [PATCH 16/70] Various Code Inspection fixes --- anknotes/Anki.py | 8 ++++-- anknotes/AnkiNotePrototype.py | 5 ++-- anknotes/Controller.py | 6 ++--- anknotes/EvernoteNotePrototype.py | 7 ++--- anknotes/EvernoteNoteTitle.py | 1 + anknotes/ankEvernote.py | 21 +++++++++------ anknotes/enums.py | 43 ++++++++++++------------------- anknotes/html.py | 3 +++ anknotes/logging.py | 3 ++- anknotes/menu.py | 4 +-- anknotes/structs.py | 3 ++- 11 files changed, 54 insertions(+), 50 deletions(-) diff --git a/anknotes/Anki.py b/anknotes/Anki.py index ee63d36..32dd15c 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -340,6 +340,7 @@ def update_evernote_note_contents(self): def insert_toc_into_see_also(self): + log = Logger() db = ankDB() db._db.row_factory = None # db._db.row_factory = lambda cursor, row: showInfo(str(row)) @@ -349,7 +350,7 @@ def insert_toc_into_see_also(self): grouped_results = {} # log(' INSERT TOCS INTO ANKI NOTES ', 'dump-insert_toc', timestamp=False, clear=True) # log('------------------------------------------------', 'dump-insert_toc', timestamp=False) - log(' <h1>INSERT TOC LINKS INTO ANKI NOTES</h1> <HR><BR><BR>', 'see_also', timestamp=False, clear=True, + log.add(' <h1>INSERT TOC LINKS INTO ANKI NOTES</h1> <HR><BR><BR>', 'see_also', timestamp=False, clear=True, extension='htm') toc_titles = {} for row in results: @@ -363,7 +364,7 @@ def insert_toc_into_see_also(self): count = 0 count_update = 0 max_count = len(grouped_results) - log = Logger() + for source_guid, toc_guids in grouped_results.items(): ankiNote = self.get_anki_note_from_evernote_guid(source_guid) if not ankiNote: @@ -464,6 +465,9 @@ def insert_toc_and_outline_contents_into_notes(self): if FIELDS.TITLE in fld.get('name'): note_title = note.fields[fld.get('ord')] continue + if not note_title: + log_error("Could not find note title for %s for insert_toc_and_outline_contents_into_notes" % note.guid) + continue note_toc = "" note_outline = "" toc_header = "" diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index b43d5ea..02d6105 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -110,10 +110,11 @@ def initialize_fields(self): # self.Title = EvernoteNoteTitle(self.Fields) def deck(self): + deck = self._deck_parent_ if EVERNOTE.TAG.TOC in self.Tags or EVERNOTE.TAG.AUTO_TOC in self.Tags: - deck = self._deck_parent_ + DECKS.TOC_SUFFIX + deck += DECKS.TOC_SUFFIX elif EVERNOTE.TAG.OUTLINE in self.Tags and EVERNOTE.TAG.OUTLINE_TESTABLE not in self.Tags: - deck = self._deck_parent_ + DECKS.OUTLINE_SUFFIX + deck += DECKS.OUTLINE_SUFFIX elif not self._deck_parent_ or mw.col.conf.get(SETTINGS.ANKI_DECK_EVERNOTE_NOTEBOOK_INTEGRATION, True): deck = self.Anki.get_deck_name_from_evernote_notebook(self.NotebookGuid, self._deck_parent_) if not deck: return None diff --git a/anknotes/Controller.py b/anknotes/Controller.py index f6fb7b5..f6db092 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -101,7 +101,7 @@ def upload_validated_notes(self, automated=False): evernote_guid, rootTitle, contents, tagNames, notebookGuid = entry.items() tagNames = tagNames.split(',') if not ANKNOTES.UPLOAD_AUTO_TOC_NOTES or ( - ANKNOTES.AUTO_TOC_NOTES_MAX > -1 and count_update + count_create >= ANKNOTES.AUTO_TOC_NOTES_MAX): + -1 < ANKNOTES.AUTO_TOC_NOTES_MAX <= count_update + count_create): continue if SIMULATE: status = EvernoteAPIStatus.Success @@ -204,7 +204,7 @@ def create_auto_toc(self): if old_values: evernote_guid, old_content = old_values if type(old_content) != type(noteBodyUnencoded): - log([rootTitle, type(old_content), type(noteBody)], 'AutoTOC-Create-Diffs\\_') + log([rootTitle, type(old_content), type(noteBodyUnencoded)], 'AutoTOC-Create-Diffs\\_') raise UnicodeWarning old_content = old_content.replace('guid-pending', evernote_guid) noteBodyUnencoded = noteBodyUnencoded.replace('guid-pending', evernote_guid) @@ -215,7 +215,7 @@ def create_auto_toc(self): contents = contents.replace('/guid-pending/', '/%s/' % evernote_guid).replace('/guid-pending/', '/%s/' % evernote_guid) log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create-Diffs\\'+rootTitle) if not ANKNOTES.UPLOAD_AUTO_TOC_NOTES or ( - ANKNOTES.AUTO_TOC_NOTES_MAX > -1 and count_update + count_create >= ANKNOTES.AUTO_TOC_NOTES_MAX): + -1 < ANKNOTES.AUTO_TOC_NOTES_MAX <= count_update + count_create): continue status, whole_note = self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid) if status.IsError: diff --git a/anknotes/EvernoteNotePrototype.py b/anknotes/EvernoteNotePrototype.py index 22af90b..e9a3147 100644 --- a/anknotes/EvernoteNotePrototype.py +++ b/anknotes/EvernoteNotePrototype.py @@ -52,14 +52,14 @@ def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid= if db_note is not None: self.Title = EvernoteNoteTitle(db_note) db_note_keys = db_note.keys() - if isinstance(db_note['tagNames'], str): - db_note['tagNames'] = unicode(db_note['tagNames'], 'utf-8') for key in ['content', 'guid', 'notebookGuid', 'updateSequenceNum', 'tagNames', 'tagGuids']: if not key in db_note_keys: log_error("FATAL ERROR: Unable to find key %s in db note %s! \n%s" % (key, self.Title.FullTitle, db_note_keys)) log("Values: \n\n" + str({k: db_note[k] for k in db_note_keys }), 'EvernoteNotePrototypeInit') else: setattr(self, upperFirst(key), db_note[key]) + if isinstance(self.TagNames, str): + self.TagNames = unicode(self.TagNames, 'utf-8') if isinstance(self.Content, str): self.Content = unicode(self.Content, 'utf-8') self.process_tags() @@ -122,15 +122,12 @@ def IsChild(self): def IsRoot(self): return self.Title.IsRoot - @property def IsAboveLevel(self, level_check): return self.Title.IsAboveLevel(level_check) - @property def IsBelowLevel(self, level_check): return self.Title.IsBelowLevel(level_check) - @property def IsLevel(self, level_check): return self.Title.IsLevel(level_check) diff --git a/anknotes/EvernoteNoteTitle.py b/anknotes/EvernoteNoteTitle.py index 63a1468..9b473b5 100644 --- a/anknotes/EvernoteNoteTitle.py +++ b/anknotes/EvernoteNoteTitle.py @@ -145,6 +145,7 @@ def titleObjectToString(title, recursion=0): return title if hasattr(title, 'FullTitle'): # log('FullTitle', 'tOTS', timestamp=False) + # noinspection PyCallingNonCallable title = title.FullTitle() if callable(title.FullTitle) else title.FullTitle elif hasattr(title, 'Title'): # log('Title', 'tOTS', timestamp=False) diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 1ff2702..8c6f037 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -162,20 +162,21 @@ def validateNoteContent(self, content, title="Note Contents"): """ return self.validateNoteBody(self.makeNoteBody(content), title) - def updateNote(self, guid, noteTitle, noteBody, tagNames=list(), parentNotebook=None, resources=[]): + def updateNote(self, guid, noteTitle, noteBody, tagNames=list(), parentNotebook=None, resources=None): """ Update a Note instance with title and body Send Note object to user's account :rtype : (EvernoteAPIStatus, evernote.edam.type.ttypes.Note) :returns Status and Note """ + if resources is None: resources = [] return self.makeNote(noteTitle, noteBody, tagNames=tagNames, parentNotebook=parentNotebook, resources=resources, guid=guid) @staticmethod - def makeNoteBody(content, resources=[], encode=True): + def makeNoteBody(content, resources=None, encode=True): ## Build body of note - + if resources is None: resources = [] nBody = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" nBody += "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" nBody += "<en-note>%s" % content @@ -192,8 +193,10 @@ def makeNoteBody(content, resources=[], encode=True): nBody = nBody.encode('utf-8') return nBody - def addNoteToMakeNoteQueue(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=[], + @staticmethod + def addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=None, guid=None): + if resources is None: resources = [] sql = "FROM %s WHERE " % TABLES.MAKE_NOTE_QUEUE if guid: sql += "guid = '%s'" % guid @@ -210,7 +213,7 @@ def addNoteToMakeNoteQueue(self, noteTitle, noteContents, tagNames=list(), paren guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook) return EvernoteAPIStatus.RequestQueued - def makeNote(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=[], guid=None, + def makeNote(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=None, guid=None, validated=None): """ Create or Update a Note instance with title and body @@ -220,8 +223,9 @@ def makeNote(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None :rtype : (EvernoteAPIStatus, EvernoteNote) :returns Status and Note """ + if resources is None: resources = [] callType = "create" - + validation_status = EvernoteAPIStatus.Uninitialized if validated is None: if not ANKNOTES.ENABLE_VALIDATION: validated = True @@ -445,11 +449,12 @@ def update_tags_db(self): raise data = [] if not hasattr(self, 'tag_data'): self.tag_data = {} + enTag = None for tag in tags: enTag = EvernoteTag(tag) self.tag_data[enTag.Guid] = enTag data.append(enTag.items()) - + if not enTag: return None ankDB().execute("DROP TABLE %s " % TABLES.EVERNOTE.TAGS) ankDB().InitTags(True) ankDB().executemany(enTag.sqlUpdateQuery(), data) @@ -477,7 +482,7 @@ def get_matching_tag_data(self, tag_guids=None, tag_names=None): log_error("FATAL ERROR: Tag %s(s) %s were not found on the Evernote Servers" % ('Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)))) raise EDAMNotFoundException() if from_guids: tags_dict = {x: self.tag_data[x] for x in tags_original} - else: tags_dict = {[k for k, v in tag_data.items() if v.Name is tag_name][0]: tag_name for tag_name in tags_original} + else: tags_dict = {[k for k, v in self.tag_data.items() if v.Name is tag_name][0]: tag_name for tag_name in tags_original} tagNamesToImport = get_tag_names_to_import(tags_dict) """:type : dict[string, EvernoteTag]""" if tagNamesToImport: diff --git a/anknotes/enums.py b/anknotes/enums.py index 813e50d..8dc5642 100644 --- a/anknotes/enums.py +++ b/anknotes/enums.py @@ -80,29 +80,20 @@ def __lt__(self, other): AutoIntEnum = auto_enum('AutoIntEnum', (IntEnum,), {}) - -class AutoIntEnum(AutoIntEnum): - def testVal(self): - return self.value - - def testName(self): - return self.name - - def testAll(self): - return self.name + ' doubled - ' + str(self.value * 2) - -class APIStatus(AutoIntEnum): - Val1=() - """:type : AutoIntEnum""" - Val2=() - """:type : AutoIntEnum""" - Val3=() - """:type : AutoIntEnum""" - Val4=() - """:type : AutoIntEnum""" - Val5=() - """:type : AutoIntEnum""" - Val6=() - """:type : AutoIntEnum""" - - Val1, Val2, Val3, Val4, Val5, Val6, Val7 = range(1, 8) +# +# +# class APIStatus(AutoIntEnum): +# Val1=() +# """:type : AutoIntEnum""" +# Val2=() +# """:type : AutoIntEnum""" +# Val3=() +# """:type : AutoIntEnum""" +# Val4=() +# """:type : AutoIntEnum""" +# Val5=() +# """:type : AutoIntEnum""" +# Val6=() +# """:type : AutoIntEnum""" +# +# Val1, Val2, Val3, Val4, Val5, Val6, Val7 = range(1, 8) diff --git a/anknotes/html.py b/anknotes/html.py index 9ef3c51..8eb4aa6 100644 --- a/anknotes/html.py +++ b/anknotes/html.py @@ -3,6 +3,9 @@ from anknotes.db import get_evernote_title_from_guid from anknotes.logging import log +try: from aqt import mw +except: pass + class MLStripper(HTMLParser): def __init__(self): HTMLParser.__init__(self) diff --git a/anknotes/logging.py b/anknotes/logging.py index 1f8149e..eefaa77 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -45,7 +45,8 @@ def counts_as_str(count, max=None): if count == max: return "All %s" % (pad_center(count, 3)) return "Total %s of %s" % (pad_center(count, 3), pad_center(max, 3)) -def show_report(title, header, log_lines=[], delay=None, log_header_prefix = ' '*5): +def show_report(title, header, log_lines=None, delay=None, log_header_prefix = ' '*5): + if log_lines is None: log_lines = [] lines = [] for line in ('<BR>'.join(header) if isinstance(header, list) else header).split('<BR>') + ('<BR>'.join(log_lines).split('<BR>') if log_lines else []): level = 0 diff --git a/anknotes/menu.py b/anknotes/menu.py index 90d89dc..2efef95 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -184,14 +184,14 @@ def find_deleted_notes(automated=False): ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anknotes_dels]) db_changed = True - show_tooltip("Deleted all %d Orphan Anknotes DB Notes" % count, 5000, 3000) + show_tooltip("Deleted all %d Orphan Anknotes DB Notes" % anknotes_dels_count, 5000, 3000) if anki_dels_count > 0: code = getText("Please enter code 'ANKI_DEL_%d' to delete your orphan Anki note(s)" % anki_dels_count)[0] if code == 'ANKI_DEL_%d' % anki_dels_count: ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anki_dels]) db_changed = True - show_tooltip("Deleted all %d Orphan Anki Notes" % count, 5000, 3000) + show_tooltip("Deleted all %d Orphan Anki Notes" % anki_dels_count, 5000, 3000) if db_changed: ankDB().commit() if missing_evernote_notes_count > 0: diff --git a/anknotes/structs.py b/anknotes/structs.py index 0cda3f9..0f185d4 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -30,7 +30,8 @@ class EvernoteStruct(object): __attr_order__ = [] __title_is_note_title = False - def __attr_from_key__(self, key): + @staticmethod + def __attr_from_key__(key): return upperFirst(key) def keys(self): From c1d3b9b728d0b095b09c8589cfdfe8b1c2188b44 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 12:07:28 -0400 Subject: [PATCH 17/70] Templates will regenerate when new, valid shard/uid are detected --- .gitignore | 3 +- anknotes/Anki.py | 188 ++++++++++++++++++++++------------------ anknotes/ankEvernote.py | 8 +- anknotes/constants.py | 4 +- anknotes/html.py | 40 +++++---- anknotes/structs.py | 2 +- 6 files changed, 136 insertions(+), 109 deletions(-) diff --git a/.gitignore b/.gitignore index 754c185..32b713d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ anknotes/extra/logs/ anknotes/extra/dev/Restart Anki.lnk -anknotes/extra/dev/anknotes.developer -anknotes/extra/dev/anknotes.developer.automate +anknotes/extra/dev/anknotes.developer* anknotes/extra/user/ *.bk diff --git a/anknotes/Anki.py b/anknotes/Anki.py index 32dd15c..93e5b04 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -131,93 +131,112 @@ def delete_anki_cards(self, evernote_guids): col.remCards(card_ids) return len(card_ids) - def add_evernote_model(self, mm, modelName, cloze=False): - model = mm.byName(modelName) - if not model: - model = mm.new(modelName) - templates = self.get_templates() - - # Add Field for Evernote GUID: - # Note that this field is first because Anki requires the first field to be unique - evernote_guid_field = mm.newField(FIELDS.EVERNOTE_GUID) - evernote_guid_field['sticky'] = True - evernote_guid_field['font'] = 'Consolas' - evernote_guid_field['size'] = 10 - mm.addField(model, evernote_guid_field) - - # Add Standard Fields: - mm.addField(model, mm.newField(FIELDS.TITLE)) - - evernote_content_field = mm.newField(FIELDS.CONTENT) - evernote_content_field['size'] = 14 - mm.addField(model, evernote_content_field) - - evernote_see_also_field = mm.newField(FIELDS.SEE_ALSO) - evernote_see_also_field['size'] = 14 - mm.addField(model, evernote_see_also_field) - - evernote_extra_field = mm.newField(FIELDS.EXTRA) - evernote_extra_field['size'] = 12 - mm.addField(model, evernote_extra_field) - - evernote_toc_field = mm.newField(FIELDS.TOC) - evernote_toc_field['size'] = 10 - mm.addField(model, evernote_toc_field) - - evernote_outline_field = mm.newField(FIELDS.OUTLINE) - evernote_outline_field['size'] = 10 - mm.addField(model, evernote_outline_field) - - # Add USN to keep track of changes vs Evernote's servers - evernote_usn_field = mm.newField(FIELDS.UPDATE_SEQUENCE_NUM) - evernote_usn_field['font'] = 'Consolas' - evernote_usn_field['size'] = 10 - mm.addField(model, evernote_usn_field) - - # Add Templates - - if modelName is MODELS.EVERNOTE_DEFAULT or modelName is MODELS.EVERNOTE_REVERSIBLE: - # Add Default Template - default_template = mm.newTemplate(TEMPLATES.EVERNOTE_DEFAULT) - default_template['qfmt'] = templates['Front'] - default_template['afmt'] = templates['Back'] - mm.addTemplate(model, default_template) - if modelName is MODELS.EVERNOTE_REVERSE_ONLY or modelName is MODELS.EVERNOTE_REVERSIBLE: - # Add Reversed Template - reversed_template = mm.newTemplate(TEMPLATES.EVERNOTE_REVERSED) - reversed_template['qfmt'] = templates['Front'] - reversed_template['afmt'] = templates['Back'] - mm.addTemplate(model, reversed_template) - if modelName is MODELS.EVERNOTE_CLOZE: - # Add Cloze Template - cloze_template = mm.newTemplate(TEMPLATES.EVERNOTE_CLOZE) - cloze_template['qfmt'] = templates['Front'] - cloze_template['afmt'] = templates['Back'] - mm.addTemplate(model, cloze_template) - - # Update Sort field to Title (By default set to GUID since it is the first field) - model['sortf'] = 1 - - # Update Model CSS - model['css'] = '@import url("_AviAnkiCSS.css");' - - # Set Type to Cloze - if cloze: - model['type'] = MODELS.TYPE_CLOZE - - # Add Model to Collection - mm.add(model) + def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): + model = mm.byName(modelName) + if model and modelName is MODELS.EVERNOTE_DEFAULT: + front = model['tmpls'][0]['qfmt'] + evernote_account_info = get_evernote_account_ids() + if not evernote_account_info.Valid: + info = ankDB().first("SELECT uid, shard, COUNT(uid) as c1, COUNT(shard) as c2 from %s GROUP BY uid, shard ORDER BY c1 DESC, c2 DESC LIMIT 1" % TABLES.SEE_ALSO) + if info and evernote_account_info.update(info[0], info[1]): forceRebuild = True + if evernote_account_info.Valid: + if not "evernote_uid = '%s'" % evernote_account_info.uid in front or not "evernote_shard = '%s'" % evernote_account_info.shard in front: forceRebuild = True + if not model or forceRebuild: + templates = self.get_templates(modelName==MODELS.EVERNOTE_DEFAULT) + if model: + for t in model['tmpls']: + # model['tmpls'][t]['qfmt'] = templates['Front'] + # model['tmpls'][t]['afmt'] = templates['Back'] + t['qfmt'] = templates['Front'] + t['afmt'] = templates['Back'] + mm.update(model) + else: + model = mm.new(modelName) + + # Add Field for Evernote GUID: + # Note that this field is first because Anki requires the first field to be unique + evernote_guid_field = mm.newField(FIELDS.EVERNOTE_GUID) + evernote_guid_field['sticky'] = True + evernote_guid_field['font'] = 'Consolas' + evernote_guid_field['size'] = 10 + mm.addField(model, evernote_guid_field) + + # Add Standard Fields: + mm.addField(model, mm.newField(FIELDS.TITLE)) + + evernote_content_field = mm.newField(FIELDS.CONTENT) + evernote_content_field['size'] = 14 + mm.addField(model, evernote_content_field) + + evernote_see_also_field = mm.newField(FIELDS.SEE_ALSO) + evernote_see_also_field['size'] = 14 + mm.addField(model, evernote_see_also_field) + + evernote_extra_field = mm.newField(FIELDS.EXTRA) + evernote_extra_field['size'] = 12 + mm.addField(model, evernote_extra_field) + + evernote_toc_field = mm.newField(FIELDS.TOC) + evernote_toc_field['size'] = 10 + mm.addField(model, evernote_toc_field) + + evernote_outline_field = mm.newField(FIELDS.OUTLINE) + evernote_outline_field['size'] = 10 + mm.addField(model, evernote_outline_field) + + # Add USN to keep track of changes vs Evernote's servers + evernote_usn_field = mm.newField(FIELDS.UPDATE_SEQUENCE_NUM) + evernote_usn_field['font'] = 'Consolas' + evernote_usn_field['size'] = 10 + mm.addField(model, evernote_usn_field) + + # Add Templates + + if modelName is MODELS.EVERNOTE_DEFAULT or modelName is MODELS.EVERNOTE_REVERSIBLE: + # Add Default Template + default_template = mm.newTemplate(TEMPLATES.EVERNOTE_DEFAULT) + default_template['qfmt'] = templates['Front'] + default_template['afmt'] = templates['Back'] + mm.addTemplate(model, default_template) + if modelName is MODELS.EVERNOTE_REVERSE_ONLY or modelName is MODELS.EVERNOTE_REVERSIBLE: + # Add Reversed Template + reversed_template = mm.newTemplate(TEMPLATES.EVERNOTE_REVERSED) + reversed_template['qfmt'] = templates['Front'] + reversed_template['afmt'] = templates['Back'] + mm.addTemplate(model, reversed_template) + if modelName is MODELS.EVERNOTE_CLOZE: + # Add Cloze Template + cloze_template = mm.newTemplate(TEMPLATES.EVERNOTE_CLOZE) + cloze_template['qfmt'] = templates['Front'] + cloze_template['afmt'] = templates['Back'] + mm.addTemplate(model, cloze_template) + + # Update Sort field to Title (By default set to GUID since it is the first field) + model['sortf'] = 1 + + # Update Model CSS + model['css'] = '@import url("_AviAnkiCSS.css");' + + # Set Type to Cloze + if cloze: + model['type'] = MODELS.TYPE_CLOZE + + # Add Model to Collection + mm.add(model) # Add Model id to list self.evernoteModels[modelName] = model['id'] + return forceRebuild - def get_templates(self): - field_names = { + def get_templates(self, forceRebuild=False): + if not self.templates or forceRebuild: + evernote_account_info = get_evernote_account_ids() + field_names = { "Title": FIELDS.TITLE, "Content": FIELDS.CONTENT, "Extra": FIELDS.EXTRA, "See Also": FIELDS.SEE_ALSO, "TOC": FIELDS.TOC, "Outline": FIELDS.OUTLINE, - "Evernote GUID Prefix": FIELDS.EVERNOTE_GUID_PREFIX, "Evernote GUID": FIELDS.EVERNOTE_GUID + "Evernote GUID Prefix": FIELDS.EVERNOTE_GUID_PREFIX, "Evernote GUID": FIELDS.EVERNOTE_GUID, + "Evernote UID": evernote_account_info.uid, "Evernote shard": evernote_account_info.shard } - if not self.templates: # Generate Front and Back Templates from HTML Template in anknotes' addon directory self.templates = {"Front": file(ANKNOTES.TEMPLATE_FRONT, 'r').read() % field_names} self.templates["Back"] = self.templates["Front"].replace("<div id='Side-Front'>", "<div id='Side-Back'>") @@ -227,10 +246,11 @@ def add_evernote_models(self): col = self.collection() mm = col.models self.evernoteModels = {} - self.add_evernote_model(mm, MODELS.EVERNOTE_DEFAULT) - self.add_evernote_model(mm, MODELS.EVERNOTE_REVERSE_ONLY) - self.add_evernote_model(mm, MODELS.EVERNOTE_REVERSIBLE) - self.add_evernote_model(mm, MODELS.EVERNOTE_CLOZE, True) + + forceRebuild = self.add_evernote_model(mm, MODELS.EVERNOTE_DEFAULT) + self.add_evernote_model(mm, MODELS.EVERNOTE_REVERSE_ONLY, forceRebuild) + self.add_evernote_model(mm, MODELS.EVERNOTE_REVERSIBLE, forceRebuild) + self.add_evernote_model(mm, MODELS.EVERNOTE_CLOZE, forceRebuild, True) def setup_ancillary_files(self): # Copy CSS file from anknotes addon directory to media directory diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 8c6f037..4ad4282 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -7,15 +7,15 @@ try: from lxml import etree eTreeImported = True -except: +except ImportError: eTreeImported = False try: from aqt.utils import openLink, getText, showInfo inAnki = True -except: +except ImportError: inAnki = False - + try: from pysqlite2 import dbapi2 as sqlite except ImportError: @@ -35,8 +35,6 @@ from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException from anknotes.evernote.api.client import EvernoteClient - - ### Anki Imports # import anki # import aqt diff --git a/anknotes/constants.py b/anknotes/constants.py index 25d75e9..00d6793 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -122,8 +122,8 @@ class SETTINGS: DEFAULT_ANKI_DECK_DEFAULT_VALUE = DECKS.DEFAULT EVERNOTE_ACCOUNT_UID = 'ankNotesEvernoteAccountUID' EVERNOTE_ACCOUNT_SHARD = 'ankNotesEvernoteAccountSHARD' - EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE = '19775535' - EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE = 's175' + EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE = '0' + EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE = 'x999' EVERNOTE_QUERY_TAGS = 'anknotesEvernoteQueryTags' EVERNOTE_QUERY_USE_TAGS = 'anknotesEvernoteQueryUseTags' EVERNOTE_QUERY_LAST_UPDATED_VALUE_RELATIVE = 'anknotesEvernoteQueryLastUpdatedValueRelative' diff --git a/anknotes/html.py b/anknotes/html.py index 8eb4aa6..1e21b10 100644 --- a/anknotes/html.py +++ b/anknotes/html.py @@ -201,7 +201,7 @@ def generate_evernote_span(title=None, element_type=None, value=None, guid=None, def get_evernote_account_ids(): global enAccountIDs - if not enAccountIDs: + if not enAccountIDs or not enAccountIDs.Valid: enAccountIDs = EvernoteAccountIDs() return enAccountIDs @@ -217,25 +217,35 @@ def tableify_lines(rows, columns=None, tr_index_offset=0, return_html=True): return trs class EvernoteAccountIDs: - uid = '0' - shard = 's100' - valid = False - - def __init__(self, uid=None, shard=None): - self.valid = False + uid = SETTINGS.EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE + shard = SETTINGS.EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE + + @property + def Valid(self): + return self.is_valid() + + def is_valid(self, uid=None, shard=None): + if uid is None: uid = self.uid + if shard is None: shard = self.shard + if not uid or not shard: return False + if uid == '0' or uid == SETTINGS.EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE or not unicode(uid).isnumeric(): return False + if shard == 's999' or uid == SETTINGS.EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE or shard[0] != 's' or not unicode(shard[1:]).isnumeric(): return False + return True + + def __init__(self, uid=None, shard=None): if uid and shard: - if self.update(uid, shard): return + if self.update(uid, shard): return try: self.uid = mw.col.conf.get(SETTINGS.EVERNOTE_ACCOUNT_UID, SETTINGS.EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE) self.shard = mw.col.conf.get(SETTINGS.EVERNOTE_ACCOUNT_SHARD, SETTINGS.EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE) + if self.Valid: return except: - self.uid = SETTINGS.EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE - self.shard = SETTINGS.EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE - return - + pass + self.uid = SETTINGS.EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE + self.shard = SETTINGS.EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE + def update(self, uid, shard): - if not uid or not shard: return False - if uid == '0' or shard == 's100': return False + if not self.is_valid(uid, shard): return False try: mw.col.conf[SETTINGS.EVERNOTE_ACCOUNT_UID] = uid mw.col.conf[SETTINGS.EVERNOTE_ACCOUNT_SHARD] = shard @@ -243,4 +253,4 @@ def update(self, uid, shard): return False self.uid = uid self.shard = shard - self.valid = True + return self.Valid diff --git a/anknotes/structs.py b/anknotes/structs.py index 0f185d4..63f9483 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -163,7 +163,7 @@ class EvernoteTag(EvernoteStruct): class EvernoteLink(EvernoteStruct): __uid__ = -1 - Shard = -1 + Shard = 'x999' Guid = "" __title__ = None """:type: EvernoteNoteTitle.EvernoteNoteTitle """ From 6bc88129ab39e3d7607b7f6e2f41c7a594e4d26f Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 14:31:53 -0400 Subject: [PATCH 18/70] Add excluded tags to settings and minor fixes --- anknotes/Controller.py | 44 +++++++++------------- anknotes/EvernoteNotePrototype.py | 2 +- anknotes/EvernoteNoteTitle.py | 3 +- anknotes/EvernoteNotes.py | 13 +++---- anknotes/__main__.py | 4 +- anknotes/constants.py | 2 + anknotes/html.py | 11 +++++- anknotes/settings.py | 62 ++++++++++++++++++++++++++++--- anknotes/structs.py | 17 +++------ 9 files changed, 100 insertions(+), 58 deletions(-) diff --git a/anknotes/Controller.py b/anknotes/Controller.py index f6db092..9ce3603 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -69,13 +69,9 @@ def process_unadded_see_also_notes(self): def upload_validated_notes(self, automated=False): self.anki.evernoteTags = [] dbRows = ankDB().all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) - number_updated = 0 - number_created = 0 - count = 0 - count_create = 0 - count_update = 0 - exist = 0 - error = 0 + number_updated = number_created = 0 + count = count_create = count_update = 0 + exist = error = 0 status = EvernoteAPIStatus.Uninitialized notes_created = [] """ @@ -142,14 +138,14 @@ def upload_validated_notes(self, automated=False): if number_updated: str_tips.append("-%-3d of these were successfully updated in Anki " % number_updated) if error > 0: str_tips.append("%d Error(s) occurred " % error) show_report(" > Upload of Validated Notes Complete", str_tip_header, str_tips) - - if len(queries1) > 0: - ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.MAKE_NOTE_QUEUE, queries1) - if len(queries2) > 0: - ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.MAKE_NOTE_QUEUE, queries2) - # log(queries1) - - ankDB().commit() + + if len(queries1) + len(queries2) > 0: + if len(queries1) > 0: + ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.MAKE_NOTE_QUEUE, queries1) + if len(queries2) > 0: + ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.MAKE_NOTE_QUEUE, queries2) + ankDB().commit() + return status, count, exist def create_auto_toc(self): @@ -159,17 +155,10 @@ def create_auto_toc(self): NotesDB.baseQuery = ANKNOTES.ROOT_TITLES_BASE_QUERY dbRows = NotesDB.populateAllNonCustomRootNotes() # dbRows = NoteDB.populateAllPotentialRootNotes() - number_updated = 0 - number_created = 0 - count = 0 - count_create = 0 - count_update = 0 - count_update_skipped = 0 - count_queued = 0 - count_queued_create = 0 - count_queued_update = 0 - exist = 0 - error = 0 + number_updated = number_created = 0 + count = count_create = count_update = count_update_skipped = 0 + count_queued = count_queued_create = count_queued_update = 0 + exist = error = 0 status = EvernoteAPIStatus.Uninitialized notes_created = [] """ @@ -213,7 +202,8 @@ def create_auto_toc(self): count_update_skipped += 1 continue contents = contents.replace('/guid-pending/', '/%s/' % evernote_guid).replace('/guid-pending/', '/%s/' % evernote_guid) - log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create-Diffs\\'+rootTitle) + log(noteBodyUnencoded, 'AutoTOC-Create-New\\'+rootTitle, clear=True) + log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create-Diffs\\'+rootTitle, clear=True) if not ANKNOTES.UPLOAD_AUTO_TOC_NOTES or ( -1 < ANKNOTES.AUTO_TOC_NOTES_MAX <= count_update + count_create): continue diff --git a/anknotes/EvernoteNotePrototype.py b/anknotes/EvernoteNotePrototype.py index e9a3147..cc0a67a 100644 --- a/anknotes/EvernoteNotePrototype.py +++ b/anknotes/EvernoteNotePrototype.py @@ -49,7 +49,7 @@ def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid= self.UpdateSequenceNum = whole_note.updateSequenceNum self.Status = EvernoteAPIStatus.Success return - if db_note is not None: + if db_note is not None: self.Title = EvernoteNoteTitle(db_note) db_note_keys = db_note.keys() for key in ['content', 'guid', 'notebookGuid', 'updateSequenceNum', 'tagNames', 'tagGuids']: diff --git a/anknotes/EvernoteNoteTitle.py b/anknotes/EvernoteNoteTitle.py index 9b473b5..9c198fc 100644 --- a/anknotes/EvernoteNoteTitle.py +++ b/anknotes/EvernoteNoteTitle.py @@ -167,7 +167,7 @@ def titleObjectToString(title, recursion=0): # log('keys[empty dict?]', 'tOTS', timestamp=False) raise else: - # log('keys[Unknown Attr]: %s' % str(keys), 'tOTS', timestamp=False) + log('keys[Unknown Attr]: %s' % str(keys), 'tOTS', timestamp=False) return "" elif 'title' in title: # log('[title]', 'tOTS', timestamp=False) @@ -205,7 +205,6 @@ def __init__(self, titleObj=None): self.__html__ = self.titleObjectToString(titleObj) self.__title__ = strip_tags_and_new_lines(self.__html__) - def generateTitleParts(title): title = EvernoteNoteTitle.titleObjectToString(title) try: diff --git a/anknotes/EvernoteNotes.py b/anknotes/EvernoteNotes.py index c824e40..64e42b5 100644 --- a/anknotes/EvernoteNotes.py +++ b/anknotes/EvernoteNotes.py @@ -302,6 +302,7 @@ def processAllRootNotesMissing(self): returns = [] """:type : list[EvernoteTOCEntry]""" ankDB().execute("DELETE FROM %s WHERE 1 " % TABLES.EVERNOTE.AUTO_TOC) + ankDB().commit() # olsz = None for rootTitleStr in self.RootNotesMissing.TitlesList: count_child = 0 @@ -357,14 +358,11 @@ def processAllRootNotesMissing(self): realTitle = realTitle[0:realTitle.index(':')] # realTitleUTF8 = realTitle.encode('utf8') notebookGuid = sorted(notebookGuids.items(), key=itemgetter(1), reverse=True)[0][0] - # if rootTitleStr.find('Antitrypsin') > -1: - # realTitleUTF8 = realTitle.encode('utf8') - # file_object = open('pytho2!nx_intro.txt', 'w') - # file_object.write(realTitleUTF8) - # file_object.close() - + + real_root_title = generateTOCTitle(realTitle) + ol = tocHierarchy.GetOrderedList() - tocEntry = EvernoteTOCEntry(realTitle, ol, ',' + ','.join(tags) + ',', notebookGuid) + tocEntry = EvernoteTOCEntry(real_root_title, ol, ',' + ','.join(tags) + ',', notebookGuid) returns.append(tocEntry) dbRows.append(tocEntry.items()) # ol = realTitleUTF8 @@ -397,7 +395,6 @@ def processAllRootNotesMissing(self): file_object.close() - # print dbRows ankDB().executemany( "INSERT INTO %s (root_title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?)" % TABLES.EVERNOTE.AUTO_TOC, dbRows) diff --git a/anknotes/__main__.py b/anknotes/__main__.py index 8f08ab9..472b9d5 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -91,11 +91,11 @@ def anknotes_profile_loaded(): # resync_with_local_db() # menu.see_also() # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) - # menu.see_also(3) + menu.see_also(3) # menu.see_also(4) # mw.progress.timer(20000, lambda : menu.find_deleted_notes(True), False) # menu.see_also([3,4]) - menu.resync_with_local_db() + # menu.resync_with_local_db() pass diff --git a/anknotes/constants.py b/anknotes/constants.py index 00d6793..1839942 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -126,6 +126,8 @@ class SETTINGS: EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE = 'x999' EVERNOTE_QUERY_TAGS = 'anknotesEvernoteQueryTags' EVERNOTE_QUERY_USE_TAGS = 'anknotesEvernoteQueryUseTags' + EVERNOTE_QUERY_EXCLUDED_TAGS = 'anknotesEvernoteQueryExcludedTags' + EVERNOTE_QUERY_USE_EXCLUDED_TAGS = 'anknotesEvernoteQueryUseExcludedTags' EVERNOTE_QUERY_LAST_UPDATED_VALUE_RELATIVE = 'anknotesEvernoteQueryLastUpdatedValueRelative' EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_DATE = 'anknotesEvernoteQueryLastUpdatedValueAbsoluteDate' EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_TIME = 'anknotesEvernoteQueryLastUpdatedValueAbsoluteDateTime' diff --git a/anknotes/html.py b/anknotes/html.py index 1e21b10..734a5e9 100644 --- a/anknotes/html.py +++ b/anknotes/html.py @@ -1,3 +1,4 @@ +import re from HTMLParser import HTMLParser from anknotes.constants import SETTINGS from anknotes.db import get_evernote_title_from_guid @@ -21,14 +22,20 @@ def get_data(self): def strip_tags(html): if html is None: return None + html = html.replace('&', '__DONT_STRIP_HTML_ENTITIES___') s = MLStripper() s.feed(html) - return s.get_data() + html = s.get_data() + html = html.replace('__DONT_STRIP_HTML_ENTITIES___', '&') + return html + # s = MLStripper() + # s.feed(html) + # return s.get_data() def strip_tags_and_new_lines(html): if html is None: return None - return strip_tags(html).replace('\r\n', ' ').replace('\r', ' ').replace('\n', ' ') + return re.sub(r'[\r\n]+', ' ', strip_tags(html)) __text_escape_phrases__ = u'&|&|\'|'|"|"|>|>|<|<'.split('|') diff --git a/anknotes/settings.py b/anknotes/settings.py index 46c0128..1be1ffe 100644 --- a/anknotes/settings.py +++ b/anknotes/settings.py @@ -46,6 +46,8 @@ def setup_evernote(self): global evernote_query_any global evernote_query_use_tags global evernote_query_tags + global evernote_query_use_excluded_tags + global evernote_query_excluded_tags global evernote_query_use_notebook global evernote_query_notebook global evernote_query_use_note_title @@ -115,6 +117,25 @@ def setup_evernote(self): hbox.addWidget(evernote_query_tags) form.addRow("Tags:", hbox) + # Evernote Query: Excluded Tags + evernote_query_excluded_tags = QLineEdit() + evernote_query_excluded_tags.setText( + mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_EXCLUDED_TAGS, '')) + evernote_query_excluded_tags.connect(evernote_query_excluded_tags, + SIGNAL("textEdited(QString)"), + update_evernote_query_excluded_tags) + + # Evernote Query: Use Excluded Tags + evernote_query_use_excluded_tags = QCheckBox(" ", self) + evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_EXCLUDED_TAGS, True)) + evernote_query_use_excluded_tags.stateChanged.connect(update_evernote_query_use_excluded_tags) + + # Add Form Row for Excluded Tags + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_excluded_tags) + hbox.addWidget(evernote_query_excluded_tags) + form.addRow("Excluded Tags:", hbox) + # Evernote Query: Search Terms evernote_query_search_terms = QLineEdit() evernote_query_search_terms.setText(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_SEARCH_TERMS, "")) @@ -445,6 +466,17 @@ def update_evernote_query_use_tags(): update_evernote_query_visibilities() +def update_evernote_query_excluded_tags(text): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_EXCLUDED_TAGS] = text + if text: evernote_query_use_excluded_tags.setChecked(True) + evernote_query_text_changed() + + +def update_evernote_query_use_excluded_tags(): + mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_EXCLUDED_TAGS] = evernote_query_use_excluded_tags.isChecked() + update_evernote_query_visibilities() + + def update_evernote_query_notebook(text): mw.col.conf[SETTINGS.EVERNOTE_QUERY_NOTEBOOK] = text if text: evernote_query_use_notebook.setChecked(True) @@ -515,6 +547,7 @@ def update_update_existing_notes(index): def evernote_query_text_changed(): tags = evernote_query_tags.text() + excluded_tags = evernote_query_excluded_tags.text() search_terms = evernote_query_search_terms.text() note_title = evernote_query_note_title.text() notebook = evernote_query_notebook.text() @@ -522,8 +555,9 @@ def evernote_query_text_changed(): search_terms_active = search_terms and evernote_query_use_search_terms.isChecked() note_title_active = note_title and evernote_query_use_note_title.isChecked() notebook_active = notebook and evernote_query_use_notebook.isChecked() + excluded_tags_active = excluded_tags and evernote_query_use_excluded_tags.isChecked() all_inactive = not ( - search_terms_active or note_title_active or notebook_active or evernote_query_use_last_updated.isChecked()) + search_terms_active or note_title_active or notebook_active or excluded_tags_active or evernote_query_use_last_updated.isChecked()) if not search_terms: evernote_query_use_search_terms.setEnabled(False) @@ -546,6 +580,13 @@ def evernote_query_text_changed(): evernote_query_use_notebook.setEnabled(True) evernote_query_use_notebook.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTEBOOK, True)) + if not excluded_tags: + evernote_query_use_excluded_tags.setEnabled(False) + evernote_query_use_excluded_tags.setChecked(False) + else: + evernote_query_use_excluded_tags.setEnabled(True) + evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_EXCLUDED_TAGS, True)) + if not tags and not all_inactive: evernote_query_use_tags.setEnabled(False) evernote_query_use_tags.setChecked(False) @@ -559,6 +600,7 @@ def evernote_query_text_changed(): def update_evernote_query_visibilities(): # is_any = evernote_query_any.isChecked() is_tags = evernote_query_use_tags.isChecked() + is_excluded_tags = evernote_query_use_excluded_tags.isChecked() is_terms = evernote_query_use_search_terms.isChecked() is_title = evernote_query_use_note_title.isChecked() is_notebook = evernote_query_use_notebook.isChecked() @@ -566,16 +608,18 @@ def update_evernote_query_visibilities(): # is_disabled_any = not evernote_query_any.isEnabled() is_disabled_tags = not evernote_query_use_tags.isEnabled() + is_disabled_excluded_tags = not evernote_query_use_excluded_tags.isEnabled() is_disabled_terms = not evernote_query_use_search_terms.isEnabled() is_disabled_title = not evernote_query_use_note_title.isEnabled() is_disabled_notebook = not evernote_query_use_notebook.isEnabled() # is_disabled_updated = not evernote_query_use_last_updated.isEnabled() - override = (not is_tags and not is_terms and not is_title and not is_notebook and not is_updated) + override = (not is_tags and not is_excluded_tags and not is_terms and not is_title and not is_notebook and not is_updated) if override: is_tags = True evernote_query_use_tags.setChecked(True) evernote_query_tags.setEnabled(is_tags or is_disabled_tags) + evernote_query_excluded_tags.setEnabled(is_excluded_tags or is_disabled_excluded_tags) evernote_query_search_terms.setEnabled(is_terms or is_disabled_terms) evernote_query_note_title.setEnabled(is_title or is_disabled_title) evernote_query_notebook.setEnabled(is_notebook or is_disabled_notebook) @@ -667,8 +711,7 @@ def update_evernote_query_last_updated_value_absolute_time(time_value): def generate_evernote_query(): - query = "" - tags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE).split(",") + query = "" if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTEBOOK, False): query += 'notebook:"%s" ' % mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_NOTEBOOK, SETTINGS.EVERNOTE_QUERY_NOTEBOOK_DEFAULT_VALUE).strip() @@ -680,8 +723,17 @@ def generate_evernote_query(): query_note_title = '"%s"' % query_note_title query += 'intitle:%s ' % query_note_title if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_TAGS, True): + tags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE).split(",") + for tag in tags: + tag = tag.strip() + if ' ' in tag: tag = '"%s"' % tag + query += 'tag:%s ' % tag + if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_EXCLUDED_TAGS, True): + tags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_EXCLUDED_TAGS, '').split(",") for tag in tags: - query += "tag:%s " % tag.strip() + tag = tag.strip() + if ' ' in tag: tag = '"%s"' % tag + query += '-tag:%s ' % tag if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_LAST_UPDATED, False): query += " updated:%s " % evernote_query_last_updated_value_get_current_value() if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_SEARCH_TERMS, False): diff --git a/anknotes/structs.py b/anknotes/structs.py index 63f9483..63bb481 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -83,6 +83,9 @@ def setAttribute(self, key, value): self.getFromDB() elif self._is_valid_attribute_(key): setattr(self, self.__attr_from_key__(key), value) + else: + log("Not valid attribute: %s" % key) + raise KeyError def setAttributeByObject(self, key, keyed_object): self.setAttribute(key, keyed_object[key]) @@ -117,28 +120,21 @@ def setFromKeyedObject(self, keyed_object, keys=None): return True def setFromListByDefaultOrder(self, args): - i = 0 max = len(self.__attr_order__) - for value in args: + for i, value in enumerate(args): if i > max: log("Unable to set attr #%d for %s to %s (Exceeds # of default attributes)" % (i, self.__class__.__name__, str_safe(value)), 'error') return self.setAttribute(self.__attr_order__[i], value) - i += 1 - # I have no idea what I was trying to do when I coded the commented out conditional statement... - # if key in self.__attr_order__: - - # else: - # log("Unable to set attr #%d for %s to %s" % (i, self.__class__.__name__, str_safe(value)), 'error') def _valid_attributes_(self): return set().union(self.__sql_columns__, [self.__sql_where__], self.__attr_order__) def _is_valid_attribute_(self, attribute): - return attribute.lower() in self._valid_attributes_() + return (attribute[0].lower() + attribute[1:]) in self._valid_attributes_() - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs): if isinstance(self.__sql_columns__, str): self.__sql_columns__ = [self.__sql_columns__] if isinstance(self.__attr_order__, str) or isinstance(self.__attr_order__, unicode): self.__attr_order__ = self.__attr_order__.replace('|', ' ').split(' ') @@ -211,7 +207,6 @@ class EvernoteTOCEntry(EvernoteStruct): TagNames = "" """:type : str""" NotebookGuid = "" - def __init__(self, *args, **kwargs): self.__attr_order__ = 'realTitle|orderedList|tagNames|notebookGuid' super(self.__class__, self).__init__(*args, **kwargs) From 5631fb6cd8ad78a343eac7d5d2d9ca94a2c1fa25 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 15:00:13 -0400 Subject: [PATCH 19/70] See Also tweak; Updated db.py to use forced sql parameters --- anknotes/__main__.py | 2 +- anknotes/db.py | 16 ++++++++-------- anknotes/menu.py | 29 ++++++++++++++++------------- anknotes/shared.py | 2 +- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/anknotes/__main__.py b/anknotes/__main__.py index 472b9d5..03e8140 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -91,7 +91,7 @@ def anknotes_profile_loaded(): # resync_with_local_db() # menu.see_also() # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) - menu.see_also(3) + # menu.see_also(3) # menu.see_also(4) # mw.progress.timer(20000, lambda : menu.find_deleted_notes(True), False) # menu.see_also([3,4]) diff --git a/anknotes/db.py b/anknotes/db.py index e3b0e9f..9793c81 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -121,23 +121,23 @@ def executescript(self, sql): def rollback(self): self._db.rollback() - def scalar(self, *a, **kw): - res = self.execute(*a, **kw).fetchone() + def scalar(self, sql, *a, **kw): + res = self.execute(sql, *a, **kw).fetchone() if res: return res[0] return None - def all(self, *a, **kw): - return self.execute(*a, **kw).fetchall() + def all(self, sql, *a, **kw): + return self.execute(sql, *a, **kw).fetchall() - def first(self, *a, **kw): - c = self.execute(*a, **kw) + def first(self, sql, *a, **kw): + c = self.execute(sql, *a, **kw) res = c.fetchone() c.close() return res - def list(self, *a, **kw): - return [x[0] for x in self.execute(*a, **kw)] + def list(self, sql, *a, **kw): + return [x[0] for x in self.execute(sql, *a, **kw)] def close(self): self._db.close() diff --git a/anknotes/menu.py b/anknotes/menu.py index 2efef95..d008234 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -48,13 +48,13 @@ def anknotes_setup_menu(): ["SEPARATOR", None], ["Step &3: Create Auto TOC Evernote Notes", lambda: see_also(3)], ["Step &4: Validate and Upload Auto TOC Notes", lambda: see_also(4)], - ["Step &5: Rebuild TOC/Outline Link Database", lambda: see_also(6)], + ["Step &5: Rebuild TOC/Outline Link Database", lambda: see_also(5)], ["SEPARATOR", None], - ["Step &6: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(7)], - ["Step &7: Update See Also Footer In Evernote Notes", lambda: see_also(8)], - ["Step &8: Validate and Upload Modified Evernote Notes", lambda: see_also(9)], + ["Step &6: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(6)], + ["Step &7: Update See Also Footer In Evernote Notes", lambda: see_also(7)], + ["Step &8: Validate and Upload Modified Evernote Notes", lambda: see_also(8)], ["SEPARATOR", None], - ["Step &9: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(10)] + ["Step &9: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(9)] ] ], ["&Maintenance Tasks", @@ -275,23 +275,26 @@ def see_also(steps=None, showAlerts=None, validationComplete=False): else: steps = [-4] if 5 in steps: - log(" > See Also: Step 5: Inserting TOC/Outline Links Into Anki Notes' See Also Field") - controller.anki.insert_toc_into_see_also() + log(" > See Also: Step 5: Rebuild TOC/Outline Link Database") + controller.anki.extract_links_from_toc() if 6 in steps: - log(" > See Also: Step 6: Update See Also Footer In Evernote Notes") + log(" > See Also: Step 6: Inserting TOC/Outline Links Into Anki Notes' See Also Field") + controller.anki.insert_toc_into_see_also() if 7 in steps: + log(" > See Also: Step 7: Update See Also Footer In Evernote Notes") + if 8 in steps: if validationComplete: - log(" > See Also: Step 7: Validate and Upload Modified Notes: Upload Validating Notes") + log(" > See Also: Step 8: Validate and Upload Modified Notes: Upload Validating Notes") upload_validated_notes(multipleSteps) else: - steps = [-7] - if 8 in steps: - log(" > See Also: Step 8: Inserting TOC/Outline Contents Into Anki Notes") + steps = [-8] + if 9 in steps: + log(" > See Also: Step 10: Inserting TOC/Outline Contents Into Anki Notes") controller.anki.insert_toc_and_outline_contents_into_notes() do_validation = steps[0]*-1 if do_validation>0: - log(" > See Also: Step %d: Validate and Upload %s Notes: Validating Notes" % (do_validation, {4: 'Auto TOC', 7: 'Modified Evernote'}[do_validation])) + log(" > See Also: Step %d: Validate and Upload %s Notes: Validating Notes" % (do_validation, {4: 'Auto TOC', 8: 'Modified Evernote'}[do_validation])) remaining_steps = remaining_steps[remaining_steps.index(do_validation)+validationComplete and 1 or 0:] validate_pending_notes(showAlerts, callback=lambda: see_also(remaining_steps, False, True)) diff --git a/anknotes/shared.py b/anknotes/shared.py index 7ffc4d3..0508f65 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -88,7 +88,7 @@ def find_evernote_links(content): content = replace_evernote_web_links(content) regex_str = r'<a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)"(?:[^>]+)?>(?P<title>.+?)</a>' ids = get_evernote_account_ids() - if not ids.valid: + if not ids.Valid: match = re.search(regex_str, content) if match: ids.update(match.group('uid'), match.group('shard')) From 6216ae17ceace3c104bf20b090adf780617300e6 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 17:10:30 -0400 Subject: [PATCH 20/70] Init DB before adding models to Anki, fix note processing --- anknotes/Anki.py | 2 ++ anknotes/AnkiNotePrototype.py | 18 ++++++++---------- anknotes/Controller.py | 2 +- anknotes/logging.py | 8 +++----- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/anknotes/Anki.py b/anknotes/Anki.py index 93e5b04..aae971f 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -470,9 +470,11 @@ def extract_links_from_toc(self): TABLES.SEE_ALSO, target_evernote_guid, toc_evernote_guid) log_sql('UPDATE_ANKI_DB: Add See Also Link: SQL Query: ' + query) ankDB().execute(query) + ankDB().commit() query_update_toc_links += delimiter + "target_evernote_guid = '%s'" % toc_evernote_guid delimiter = " OR " ankDB().execute(query_update_toc_links) + ankDB().commit() def insert_toc_and_outline_contents_into_notes(self): linked_notes_fields = {} diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 02d6105..18efa78 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -178,7 +178,7 @@ def step_2_modify_image_links(): # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page # Step 2.1: Modify HTML links to Dropbox images dropbox_image_url_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))(?P<QueryString>(?:\?dl=(?:0|1))?)' - dropbox_image_src_subst = r'<a href="\g<URL>}\g<QueryString>}"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' + dropbox_image_src_subst = r'<a href="\g<URL>\g<QueryString>"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="%s".*?>(?P<Title>.+?)</a>' % dropbox_image_url_regex, dropbox_image_src_subst % "'\g<Title>'", self.Fields[FIELDS.CONTENT]) @@ -202,7 +202,7 @@ def step_3_occlude_text(): self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') ################################### Step 4: Automatically Occlude Text in <<Double Angle Brackets>> - self.Fields[FIELDS.CONTENT] = re.sub(r'<<(.+?)>>', r'<<<span class="occluded">$1</span>>>', self.Fields[FIELDS.CONTENT]) + self.Fields[FIELDS.CONTENT] = re.sub(r'< ?< ?(.+?)> ?> ?', r'<<<span class="occluded">\1</span>>>', self.Fields[FIELDS.CONTENT]) def step_5_create_cloze_fields(): ################################### Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. @@ -210,14 +210,12 @@ def step_5_create_cloze_fields(): def step_6_process_see_also_links(): ################################### Step 6: Process "See Also: " Links - see_also_match = regex_see_also().search(self.Fields[FIELDS.CONTENT]) - + see_also_match = regex_see_also().search(self.Fields[FIELDS.CONTENT]) if not see_also_match: if self.Fields[FIELDS.CONTENT].find("See Also") > -1: log("No See Also Content Found, but phrase 'See Also' exists in " + self.Title.FullTitle + " \n" + self.Fields[FIELDS.CONTENT]) raise ValueError - return - + return self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group(0), see_also_match.group('Suffix')) self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<div><b><br/></b></div></en-note>', '</en-note>') see_also = see_also_match.group('SeeAlso') @@ -413,10 +411,10 @@ def update_note_fields(self): # 'AddUpdateNoteTest') for update in field_updates: self.log_update(update) - if not fld_content_ord is -1: - debug_fields = list(self.note.fields) - del debug_fields[fld_content_ord] - log_dump(debug_fields, "- > UPDATE_NOTE → anki.notes.Note: FIELDS ") + # if not fld_content_ord is -1: + # debug_fields = list(self.note.fields) + # del debug_fields[fld_content_ord] + # log_dump(debug_fields, "- > UPDATE_NOTE → anki.notes.Note: FIELDS ") if flag_changed: self.Changed = True return flag_changed diff --git a/anknotes/Controller.py b/anknotes/Controller.py index 9ce3603..dbf564b 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -45,8 +45,8 @@ def __init__(self): self.anki = Anki() self.anki.deck = mw.col.conf.get(SETTINGS.DEFAULT_ANKI_DECK, SETTINGS.DEFAULT_ANKI_DECK_DEFAULT_VALUE) self.anki.setup_ancillary_files() - self.anki.add_evernote_models() ankDB().Init() + self.anki.add_evernote_models() self.evernote = Evernote() def test_anki(self, title, evernote_guid, filename=""): diff --git a/anknotes/logging.py b/anknotes/logging.py index eefaa77..c6ca567 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -159,20 +159,19 @@ class Logger(object): caller_info=None default_filename=None def wrap_filename(self, filename=None): - if filename is None: filename = self.default_filename + if filename is None: filename = self.default_filename if self.base_path is not None: filename = os.path.join(self.base_path, filename if filename else '') return filename - def dump(self, filename=None, *args, **kwargs): + def dump(self, obj, title='', filename=None, *args, **kwargs): filename = self.wrap_filename(filename) - log_dump(filename=filename, *args, **kwargs) + log_dump(obj=obj, title=title, filename=filename, *args, **kwargs) def blank(self, filename=None, *args, **kwargs): filename = self.wrap_filename(filename) log_blank(filename=filename, *args, **kwargs) - # def banner(self, title, filename=None, length=80, append_newline=True, do_print=False): def banner(self, title, filename=None, *args, **kwargs): filename = self.wrap_filename(filename) log_banner(title=title, filename=filename, *args, **kwargs) @@ -181,7 +180,6 @@ def go(self, content=None, filename=None, wrap_filename=True, *args, **kwargs): if wrap_filename: filename = self.wrap_filename(filename) log(content=content, filename=filename, *args, **kwargs) - # content=None, filename='', prefix='', clear=False, extension='log', replace_newline=None, do_print=False): def plain(self, content=None, filename=None, *args, **kwargs): filename=self.wrap_filename(filename) log_plain(content=content, filename=filename, *args, **kwargs) From c25a88bde71d9b830d92435aef428241dd441221 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 23 Sep 2015 20:33:31 -0400 Subject: [PATCH 21/70] Support for multiple Anki profiles: User will need to fill ANKI_PROFILE_NAME under settings. --- anknotes/constants.py | 1 + anknotes/db.py | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/anknotes/constants.py b/anknotes/constants.py index 1839942..a895891 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -115,6 +115,7 @@ class EVERNOTE: class SETTINGS: + ANKI_PROFILE_NAME = 'Evernote' EVERNOTE_LAST_IMPORT = "ankNotesEvernoteLastAutoImport" ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX = "ankNotesCheckableMenuItems" KEEP_EVERNOTE_TAGS_DEFAULT_VALUE = True diff --git a/anknotes/db.py b/anknotes/db.py index 9793c81..0466a67 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -1,6 +1,7 @@ ### Python Imports from sqlite3 import dbapi2 as sqlite import time +import os ### Anki Shared Imports from anknotes.constants import * @@ -27,7 +28,13 @@ def ankDB(reset=False): global ankNotesDBInstance, dbLocal if not ankNotesDBInstance or reset: if dbLocal: - ankNotesDBInstance = ank_DB(os.path.join(PATH, '..\\..\\Evernote\\collection.anki2')) + anki_profile_path_root = os.path.join(PATH, '..\\..\\') + anki_profile_path = os.path.join(anki_profile_path_root, SETTINGS.ANKI_PROFILE_NAME) + if SETTINGS.ANKI_PROFILE_NAME =='' or not os.path.is_dir(anki_profile_path): + dirs = [x for x in os.listdir(anki_profile_path_root) if os.path.isdir(os.path.join(anki_profile_path_root, x)) and x is not 'addons'] + assert len(dirs>0) + anki_profile_path = os.path.join(anki_profile_path_root, dirs[0]) + ankNotesDBInstance = ank_DB(os.path.join(anki_profile_path, 'collection.anki2')) else: ankNotesDBInstance = ank_DB() return ankNotesDBInstance From 94564f581be2e0beecf00b2092ea24c227b753ff Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Thu, 24 Sep 2015 07:12:54 -0400 Subject: [PATCH 22/70] Improvements to note template and CSS; Auto Update note models; better profile detection --- .gitignore | 1 + anknotes/Anki.py | 17 +- anknotes/AnkiNotePrototype.py | 41 +-- anknotes/Controller.py | 5 +- anknotes/EvernoteNoteFetcher.py | 3 +- anknotes/__main__.py | 6 +- anknotes/ankEvernote.py | 12 +- anknotes/bare.py | 59 +---- anknotes/constants.py | 4 +- anknotes/db.py | 20 +- anknotes/error.py | 3 + .../ancillary/FrontTemplate-Processed.htm | 123 --------- anknotes/extra/ancillary/FrontTemplate.htm | 26 +- anknotes/extra/ancillary/_AviAnkiCSS.css | 247 +++++++++++------- anknotes/extra/dev/restart_anki_automate.bat | 4 + anknotes/menu.py | 3 - anknotes/shared.py | 3 +- remove_tags.py | 19 ++ 18 files changed, 249 insertions(+), 347 deletions(-) delete mode 100644 anknotes/extra/ancillary/FrontTemplate-Processed.htm create mode 100644 anknotes/extra/dev/restart_anki_automate.bat create mode 100644 remove_tags.py diff --git a/.gitignore b/.gitignore index 32b713d..0498aad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +anknotes/extra/ancillary/FrontTemplate-Processed.htm anknotes/extra/logs/ anknotes/extra/dev/Restart Anki.lnk anknotes/extra/dev/anknotes.developer* diff --git a/anknotes/Anki.py b/anknotes/Anki.py index aae971f..5541157 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -131,8 +131,15 @@ def delete_anki_cards(self, evernote_guids): col.remCards(card_ids) return len(card_ids) + @staticmethod + def get_evernote_model_styles(): + if ANKNOTES.IMPORT_MODEL_STYLES_AS_URL: return '@import url("%s");' % ANKNOTES.CSS + return file(os.path.join(ANKNOTES.FOLDER_ANCILLARY, ANKNOTES.CSS), 'r').read() + def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): model = mm.byName(modelName) + model_css = self.get_evernote_model_styles() + templates = self.get_templates(modelName==MODELS.EVERNOTE_DEFAULT) if model and modelName is MODELS.EVERNOTE_DEFAULT: front = model['tmpls'][0]['qfmt'] evernote_account_info = get_evernote_account_ids() @@ -141,14 +148,14 @@ def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): if info and evernote_account_info.update(info[0], info[1]): forceRebuild = True if evernote_account_info.Valid: if not "evernote_uid = '%s'" % evernote_account_info.uid in front or not "evernote_shard = '%s'" % evernote_account_info.shard in front: forceRebuild = True - if not model or forceRebuild: - templates = self.get_templates(modelName==MODELS.EVERNOTE_DEFAULT) + if model['css'] != model_css: forceRebuild = True + if model['tmpls'][0]['qfmt'] != templates['Front']: forceRebuild = True + if not model or forceRebuild: if model: for t in model['tmpls']: - # model['tmpls'][t]['qfmt'] = templates['Front'] - # model['tmpls'][t]['afmt'] = templates['Back'] t['qfmt'] = templates['Front'] t['afmt'] = templates['Back'] + model['css'] = model_css mm.update(model) else: model = mm.new(modelName) @@ -215,7 +222,7 @@ def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): model['sortf'] = 1 # Update Model CSS - model['css'] = '@import url("_AviAnkiCSS.css");' + model['css'] = model_css # Set Type to Cloze if cloze: diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 18efa78..96c977b 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -177,16 +177,16 @@ def step_2_modify_image_links(): # As a work around, this code will convert any link to an image on Dropbox, to an embedded <img> tag. # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page # Step 2.1: Modify HTML links to Dropbox images - dropbox_image_url_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))(?P<QueryString>(?:\?dl=(?:0|1))?)' + dropbox_image_url_base_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))' + dropbox_image_url_html_link_regex = dropbox_image_url_base_regex + r'(?P<QueryString>(?:\?dl=(?:0|1))?)' dropbox_image_src_subst = r'<a href="\g<URL>\g<QueryString>"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' - self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="%s".*?>(?P<Title>.+?)</a>' % dropbox_image_url_regex, + self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="%s"[^>]*>(?P<Title>.+?)</a>' % dropbox_image_url_html_link_regex, dropbox_image_src_subst % "'\g<Title>'", self.Fields[FIELDS.CONTENT]) # Step 2.2: Modify Plain-text links to Dropbox images try: - dropbox_image_url_regex = dropbox_image_url_regex.replace('(?P<QueryString>(?:\?dl=(?:0|1))?)', - '(?P<QueryString>\?dl=(?:0|1))') - self.Fields[FIELDS.CONTENT] = re.sub(dropbox_image_url_regex, dropbox_image_src_subst % "From Plain-Text Link", self.Fields[FIELDS.CONTENT]) + dropbox_image_url_regex = dropbox_image_url_base_regex + r'(?P<QueryString>\?dl=(?:0|1))(?P<Suffix>"?[^">])' + self.Fields[FIELDS.CONTENT] = re.sub(dropbox_image_url_regex, (dropbox_image_src_subst % "From Plain-Text Link") + r'\g<Suffix>', self.Fields[FIELDS.CONTENT]) except: log_error("\nERROR processing note, Step 2.2. Content: %s" % self.Fields[FIELDS.CONTENT]) @@ -200,13 +200,13 @@ def step_3_occlude_text(): # I currently use white text in Evernote to display information that I want to be initially hidden, but visible when desired by selecting the white text. # We will change the white text to a special "occluded" CSS class so it can be visible on the back of cards, and also so we can adjust the color for the front of cards when using night mode self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') - + ################################### Step 4: Automatically Occlude Text in <<Double Angle Brackets>> - self.Fields[FIELDS.CONTENT] = re.sub(r'< ?< ?(.+?)> ?> ?', r'<<<span class="occluded">\1</span>>>', self.Fields[FIELDS.CONTENT]) - + self.Fields[FIELDS.CONTENT] = re.sub("(?s)(?P<Prefix><|<) ?(?P=Prefix) ?(?P<PrefixKeep>(?:</div>)?)(?P<OccludedText>.+?)(?P<Suffix>>|>) ?(?P=Suffix) ?", r'<<\g<PrefixKeep><div class="occluded">\g<OccludedText></div>>>', self.Fields[FIELDS.CONTENT]) + def step_5_create_cloze_fields(): ################################### Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. - self.Fields[FIELDS.CONTENT] = re.sub(r'([^{]){(.+?)}([^}])', self.evernote_cloze_regex, self.Fields[FIELDS.CONTENT]) + self.Fields[FIELDS.CONTENT] = re.sub(r'([^{]){([^{].*?)}([^}])', self.evernote_cloze_regex, self.Fields[FIELDS.CONTENT]) def step_6_process_see_also_links(): ################################### Step 6: Process "See Also: " Links @@ -297,7 +297,6 @@ def update_note_model(self): else: cmap[1] = None self.log_update("Changing model:\n From: '%s' \n To: '%s'" % (modelNameOld, modelNameNew)) - # self.log_update("NID %d cmap- %s" % (self.note.id, str(cmap))) mm.change(modelOld, [self.note.id], modelNew, fmap, cmap) self.Changed = True return True @@ -362,8 +361,7 @@ def update_note_deck(self): def update_note_fields(self): fields_to_update = [FIELDS.TITLE, FIELDS.CONTENT, FIELDS.SEE_ALSO, FIELDS.UPDATE_SEQUENCE_NUM] fld_content_ord = -1 - flag_changed = False - # log_dump({'self.note.fields': self.note.fields, 'self.note._model.flds': self.note._model['flds']}, "- > UPDATE_NOTE → anki.notes.Note: _model: flds") + flag_changed = False field_updates = [] fields_updated = {} for fld in self.note._model['flds']: @@ -396,31 +394,12 @@ def update_note_fields(self): self.Guid, self.Fields[FIELDS.TITLE], field_to_update, str(fld.get('ord')), len(self.note.fields))) raise - # if len(field_updates) == 2: - # if FIELDS.SEE_ALSO in fields_updated and FIELDS.CONTENT in fields_updated: - # fc_test1 = (self._unprocessed_content_ == fields_updated[FIELDS.CONTENT]) - # fc_test2 = (self._unprocessed_see_also_ == fields_updated[FIELDS.SEE_ALSO]) - # fc_test = fc_test1 and fc_test2 - # if fc_test: - # field_updates = [] - # self.log_update('(Detected See Also Contents)') - # elif fc_test1: - # del field_updates[0] - # else: - # log_dump([fc_test1, fc_test2, self._unprocessed_content_, '-' + fields_updated[FIELDS.CONTENT]], - # 'AddUpdateNoteTest') for update in field_updates: self.log_update(update) - # if not fld_content_ord is -1: - # debug_fields = list(self.note.fields) - # del debug_fields[fld_content_ord] - # log_dump(debug_fields, "- > UPDATE_NOTE → anki.notes.Note: FIELDS ") if flag_changed: self.Changed = True return flag_changed def update_note(self): - # col = self.anki.collection() - assert self.Fields[FIELDS.CONTENT] == self.Fields[FIELDS.CONTENT] self.note = self.BaseNote self.logged = False if not self.BaseNote: diff --git a/anknotes/Controller.py b/anknotes/Controller.py index dbf564b..864ff5f 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -267,9 +267,8 @@ def proceed(self, auto_paging=False): self.evernoteImporter.ManualGUIDs = self.ManualGUIDs self.evernoteImporter.proceed(auto_paging) - def resync_with_local_db(self): - self.evernote.initialize_note_store() - evernote_guids = get_all_local_db_guids() + def resync_with_local_db(self): + evernote_guids = get_all_local_db_guids() results = self.evernote.create_evernote_notes(evernote_guids, use_local_db_only=True) """:type: EvernoteNoteFetcherResults""" number = self.anki.update_evernote_notes(results.Notes, log_update_if_unchanged=False) diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index 3697011..35a8257 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -70,7 +70,7 @@ def getNoteLocal(self): """:type : sqlite.Row""" if not db_note: return False if not self.use_local_db_only: - log(" > getNoteLocal: GUID: '%s': %-40s" % (self.evernote_guid, db_note['title']), 'api') + log(' '*20 + "> getNoteLocal: GUID: '%s': %-40s" % (self.evernote_guid, db_note['title']), 'api') assert db_note['guid'] == self.evernote_guid self.reportSuccess(EvernoteNotePrototype(db_note=db_note), 1) self.setNoteTags(tag_names=self.result.Note.TagNames) @@ -123,6 +123,7 @@ def addNoteFromServerToDB(self, whole_note=None, tag_names=None): ankDB().commit() def getNoteRemoteAPICall(self): + self.evernote.initialize_note_store() api_action_str = u'trying to retrieve a note. We will save the notes downloaded thus far.' log_api(" > getNote [%3d]" % (self.api_calls + 1), "GUID: '%s'" % self.evernote_guid) diff --git a/anknotes/__main__.py b/anknotes/__main__.py index 03e8140..0500b04 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- ### Python Imports +import os try: from pysqlite2 import dbapi2 as sqlite except ImportError: @@ -78,6 +79,9 @@ def anknotes_search_hook(search): search['edited'] = _findEdited def anknotes_profile_loaded(): + if not os.path.exists(os.path.dirname(ANKNOTES.LAST_PROFILE_LOCATION)): os.makedirs(os.path.dirname(ANKNOTES.LAST_PROFILE_LOCATION)) + with open(ANKNOTES.LAST_PROFILE_LOCATION, 'w+') as myFile: + print>> myFile, mw.pm.name menu.anknotes_load_menu_settings() if ANKNOTES.ENABLE_VALIDATION and ANKNOTES.AUTOMATE_VALIDATION: menu.upload_validated_notes(True) @@ -88,7 +92,7 @@ def anknotes_profile_loaded(): Add a function here and it will automatically run on profile load You must create the files 'anknotes.developer' and 'anknotes.developer.automate' in the /extra/dev/ folder ''' - # resync_with_local_db() + menu.resync_with_local_db() # menu.see_also() # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) # menu.see_also(3) diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 4ad4282..f985945 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -343,12 +343,12 @@ def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): def check_ancillary_data_up_to_date(self): if not self.check_tags_up_to_date(): - self.update_tags_db() + self.update_tags_db("Tags were not up to date when checking ancillary data") if not self.check_notebooks_up_to_date(): self.update_notebook_db() def update_ancillary_data(self): - self.update_tags_db() + self.update_tags_db("Manual call to update ancillary data") self.update_notebook_db() def check_notebook_metadata(self, notes): @@ -385,6 +385,7 @@ def check_notebooks_up_to_date(self): return True def update_notebook_db(self): + self.initialize_note_store() api_action_str = u'trying to update Evernote notebooks.' log_api("listNotebooks") try: @@ -428,9 +429,10 @@ def check_tags_up_to_date(self): self.tag_data[tag_guid] = tag return True - def update_tags_db(self): + def update_tags_db(self, reason_str=''): + self.initialize_note_store() api_action_str = u'trying to update Evernote tags.' - log_api("listTags") + log_api("listTags" + (': ' + reason_str) if reason_str else '') try: tags = self.noteStore.listTags(self.token) @@ -474,7 +476,7 @@ def get_matching_tag_data(self, tag_guids=None, tag_names=None): from_guids = True if (tag_guids is not None) else False tags_original = tag_guids if from_guids else tag_names if self.get_missing_tags(tags_original, from_guids): - self.update_tags_db() + self.update_tags_db("Missing Tags Were found when attempting to get matching tag data for tags: %s" % ', '.join(tags_original)) missing_tags = self.get_missing_tags(tags_original, from_guids) if missing_tags: log_error("FATAL ERROR: Tag %s(s) %s were not found on the Evernote Servers" % ('Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)))) diff --git a/anknotes/bare.py b/anknotes/bare.py index dfe60cf..652ddac 100644 --- a/anknotes/bare.py +++ b/anknotes/bare.py @@ -171,93 +171,41 @@ def print_results(): log.plain(diffify(n.old.see_also.updated,split=False), 'SeeAlsoDiff\\Original\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) log.plain(diffify(n.new.see_also.updated,split=False), 'SeeAlsoDiff\\New\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) log.plain(diff + '\n', 'SeeAlsoDiff\\__All') - # diff = generate_diff(see_also_replace_old, see_also_replace_new) - # log_plain(diff, 'SeeAlsoDiff\\Diff\\' + enNote.FullTitle, clear=True) - # log_plain(see_also_replace_old, 'SeeAlsoDiff\\Original\\' + enNote.FullTitle, clear=True) - # log_plain(see_also_replace_new, 'SeeAlsoDiff\\New\\' + enNote.FullTitle, clear=True) - # log_plain(diff + '\n' , logall) @clockit def process_note(): n.old.content = notes.version.pstrings(enNote.Content) - # xx = n.old.content.original.match - # see_also_match_old = rgx.search(old_content) - # see_also_match_old = n.old.content.regex_original.__matchobject__ - # if not see_also_match_old: - if not n.old.content.regex_original.successful_match: - # log.go("Could not get see also match for %s" % target_evernote_guid) - - # new_content = old_content.replace('</en-note>', '<div><span><br/></span></div>' + n.new.see_also + '\n</en-note>') + if not n.old.content.regex_original.successful_match: n.new.content = notes.version.pstrings(n.old.content.original.replace('</en-note>', '<div><span><br/></span></div>' + n.new.see_also.original + '\n</en-note>')) - # see_also_replace_new = new_content - # see_also_replace_old = old_content - # ????????????n.new.see_also.updated = str_process(n.new.content.original) n.new.see_also.updated = str_process(n.new.content.original) n.old.see_also.updated = str_process(n.old.content.original) log.plain((target_evernote_guid + '<BR>' if target_evernote_guid != enNote.Guid else '') + enNote.Guid + '<BR>' + ', '.join(enNote.TagNames) + '<HR>' + enNote.Content + '<HR>' + n.new.see_also.updated, 'SeeAlsoMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) n.match_type = 'V1' else: - # see_also_old = see_also_match_old.group(0) - # see_also_old = n.old.content.regex_original.main n.old.see_also = notes.version.pstrings(n.old.content.regex_original.main) - n.match_type = 'V2' - # see_also_old_processed = str_process(see_also_old) - # see_also_old_processed = n.old.see_also.processed - - # see_also_match_old_processed = rgx.search(see_also_old_processed) - # see_also_match_old_processed = n.old.content.original.match.processed.__matchobject__ - # see_also_match_old_processed = n.old.see_also.regex_processed - # if n.old.content.original.match.processed.successful_match: + n.match_type = 'V2' if n.old.see_also.regex_processed.successful_match: - # n.old.content.processed.content = n.old.content.original.subject.replace(n.old.content.original.match.original.subject, n.old.content.original.match.processed.subject) assert True or str_process(n.old.content.regex_original.main) is n.old.content.regex_processed.main n.old.content.updated = n.old.content.original.replace(n.old.content.regex_original.main, str_process(n.old.content.regex_original.main)) - # old_content = old_content.replace(see_also_old, see_also_old_processed) - # see_also_match_old = see_also_match_old_processed n.old.see_also.useProcessed() - # log.go("Able to use processed old see also content") - n.match_type += 'V3' - # see_also_match_old = n.old.see_also.updated - # xxx = n.old.content.original.match.processed - - # see_also_old_group_only = see_also_match_old.group('SeeAlso') - # see_also_old_group_only = n.old.content.original.match.processed.see_also.original.content - # see_also_old_group_only = n.old.see_also.regex_updated.see_also - # see_also_old_group_only_processed = str_process(see_also_old_group_only) - # see_also_old_group_only_processed = n.old.content.original.match.processed.see_also.processed.content - # see_also_old = str_process(see_also_match.group(0)) + n.match_type += 'V3' n.new.see_also.regex_original.subject = n.new.see_also.original + '</en-note>' - # see_also_match_new = rgx.search(see_also_new + '</en-note>') - # if not see_also_match_new: if not n.new.see_also.regex_original.successful_match: - # log.go("Could not get see also new match for %s" % target_evernote_guid) - # log_plain(enNote.Guid + '\n' + ', '.join(enNote.TagNames) + '\n' + see_also_new, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, clear=True) log.plain(enNote.Guid + '\n' + ', '.join(enNote.TagNames) + '\n' + n.new.see_also.original.content, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) - # see_also_replace_old = see_also_old_group_only_processed see_also_replace_old = n.old.content.original.match.processed.see_also.processed.content n.old.see_also.updated = n.old.content.regex_updated.see_also - # see_also_replace_new = see_also_new_processed - # see_also_replace_new = n.new.see_also.processed.content n.new.see_also.updated = n.new.see_also.processed n.match_type + 'V4' else: - # see_also_replace_old = see_also_match_old.group('SeeAlsoContent') - # see_also_replace_old = n.old.content.original.match.processed.see_also_content assert (n.old.content.regex_processed.see_also_content == notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content) n.old.see_also.updated = notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content - # see_also_replace_new = see_also_match_new.group('SeeAlsoContent') - # see_also_replace_new = n.new.see_also.original.see_also_content - # n.new.see_also.updated = n.new.see_also.regex_original.see_also_content n.new.see_also.updated = str_process(n.new.see_also.regex_original.see_also_content) n.match_type += 'V5' n.new.content.updated = n.old.content.updated.replace(n.old.see_also.updated, n.new.see_also.updated) - # new_content = old_content.replace(see_also_replace_old, see_also_replace_new) - # n.new.content = notes.version.pmatches() log = Logger(default_filename='SeeAlsoDiff\\__ALL', rm_path=True) results = [x[0] for x in ankDB().all( "SELECT DISTINCT target_evernote_guid FROM %s WHERE 1 ORDER BY title ASC " % TABLES.SEE_ALSO)] changed = 0 - # rm_log_path(subfolders_only=True) log.banner("UPDATING EVERNOTE SEE ALSO CONTENT", do_print=True) tmr = stopwatch.Timer(max=len(results), interval=25) tmr.max = len(results) @@ -276,7 +224,6 @@ def process_note(): n.new.see_also = notes.version.pstrings(flds.split("\x1f")[FIELDS.SEE_ALSO_FIELDS_ORD]) process_note() if n.match_type != 'V1' and str_process(n.old.see_also.updated) == n.new.see_also.updated: continue - # if see_also_replace_old == see_also_replace_new: continue print_results() changed += 1 enNote.Content = n.new.content.updated diff --git a/anknotes/constants.py b/anknotes/constants.py index a895891..f98123a 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -30,6 +30,7 @@ class ANKNOTES: LOG_FDN_ANKNOTES_TITLE_MISMATCHES = LOG_FDN_ANKI_ORPHANS + 'AnknotesTitleMismatches' LOG_FDN_ANKNOTES_ORPHANS = LOG_FDN_ANKI_ORPHANS + 'AnknotesOrphans' LOG_FDN_ANKI_ORPHANS += 'AnkiOrphans' + LAST_PROFILE_LOCATION = os.path.join(FOLDER_USER, 'anki.profile') ICON_EVERNOTE_WEB = os.path.join(FOLDER_GRAPHICS, u'evernote_web.ico') IMAGE_EVERNOTE_WEB = ICON_EVERNOTE_WEB.replace('.ico', '.png') ICON_EVERNOTE_ARTCORE = os.path.join(FOLDER_GRAPHICS, u'evernote_artcore.ico') @@ -46,6 +47,7 @@ class ANKNOTES: AUTOMATE_VALIDATION = True ROOT_TITLES_BASE_QUERY = "notebookGuid != 'fdccbccf-ee70-4069-a587-82772a96d9d3'" NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING = False + IMPORT_MODEL_STYLES_AS_URL = True class MODELS: EVERNOTE_DEFAULT = 'evernote_note' @@ -115,7 +117,7 @@ class EVERNOTE: class SETTINGS: - ANKI_PROFILE_NAME = 'Evernote' + ANKI_PROFILE_NAME = '' EVERNOTE_LAST_IMPORT = "ankNotesEvernoteLastAutoImport" ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX = "ankNotesCheckableMenuItems" KEEP_EVERNOTE_TAGS_DEFAULT_VALUE = True diff --git a/anknotes/db.py b/anknotes/db.py index 0466a67..a517b8e 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -14,6 +14,18 @@ ankNotesDBInstance = None dbLocal = False +def last_anki_profile_name(): + anki_profile_path_root = os.path.abspath(os.path.join(os.path.dirname(PATH), '..' + os.path.sep)) + print anki_profile_path_root + name = SETTINGS.ANKI_PROFILE_NAME + if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name + if os.path.isfile(ANKNOTES.LAST_PROFILE_LOCATION): name = file(ANKNOTES.LAST_PROFILE_LOCATION, 'r').read().strip() + if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name + name = SETTINGS.ANKI_PROFILE_NAME + if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name + dirs = [x for x in os.listdir(anki_profile_path_root) if os.path.isdir(os.path.join(anki_profile_path_root, x)) and x is not 'addons'] + if not dirs: return "" + return dirs[0] def ankDBSetLocal(): global dbLocal @@ -28,13 +40,7 @@ def ankDB(reset=False): global ankNotesDBInstance, dbLocal if not ankNotesDBInstance or reset: if dbLocal: - anki_profile_path_root = os.path.join(PATH, '..\\..\\') - anki_profile_path = os.path.join(anki_profile_path_root, SETTINGS.ANKI_PROFILE_NAME) - if SETTINGS.ANKI_PROFILE_NAME =='' or not os.path.is_dir(anki_profile_path): - dirs = [x for x in os.listdir(anki_profile_path_root) if os.path.isdir(os.path.join(anki_profile_path_root, x)) and x is not 'addons'] - assert len(dirs>0) - anki_profile_path = os.path.join(anki_profile_path_root, dirs[0]) - ankNotesDBInstance = ank_DB(os.path.join(anki_profile_path, 'collection.anki2')) + ankNotesDBInstance = ank_DB( os.path.abspath(os.path.join(PATH, '..' + os.path.sep , '..' + os.path.sep , last_anki_profile_name() + os.path.sep, 'collection.anki2'))) else: ankNotesDBInstance = ank_DB() return ankNotesDBInstance diff --git a/anknotes/error.py b/anknotes/error.py index daa69f5..059a9cc 100644 --- a/anknotes/error.py +++ b/anknotes/error.py @@ -21,6 +21,9 @@ def HandleSocketError(e, strErrorBase): errno.WSAECONNRESET: "Connection was reset or forcibly closed by the remote host", errno.ETIMEDOUT: "Connection timed out" } + if errorcode not in errno.errorcode: + log_error("Unknown socket error (%s) occurred: %s" % (str(errorcode), str(e))) + return False error_constant = errno.errorcode[errorcode] if errorcode in friendly_error_msgs: strError = friendly_error_msgs[errorcode] diff --git a/anknotes/extra/ancillary/FrontTemplate-Processed.htm b/anknotes/extra/ancillary/FrontTemplate-Processed.htm deleted file mode 100644 index abca9e6..0000000 --- a/anknotes/extra/ancillary/FrontTemplate-Processed.htm +++ /dev/null @@ -1,123 +0,0 @@ -<div id='Template-{{Type}}'> -<div id='Card-{{Card}}'> -<div id='Deck-{{Deck}}'> -<div id='Side-Back'> -<header id='Header-Avi'><h2> -<div id='Field-Title-Prompt'>What is the Note's Title???</div> -<div id='Field-Title'>{{Title}} </div> -</h2></header> -<div id='debug'></div> -<hr id=answer> -{{#See_Also}} -<div id='Header-Links'> -<span class='Field-See_Also'> -<a href='javascript:;' onclick='scrollToElementVisible("Field-See_Also")' class='header'>See Also</a>: -</span> -{{#TOC}} -<span class='Field-TOC'> -<a href='javascript:;' onclick='scrollToElementToggle("Field-TOC")' class='header'>[TOC]</a> -</span> -{{/TOC}} - -{{#Outline}} -{{#TOC}} -<span class='Field-See_Also'> | </span> -{{/TOC}} -<span class='Field-Outline'> -<a href='javascript:;' onclick='scrollToElementToggle("Field-Outline")' class='header'>[Outline]</a> -</span> -{{/Outline}} -</div> -{{/See_Also}} - -<div id='Field-Content'>{{Content}}</div> -<div id='Field-Cloze-Content'>{{cloze:Content}}</div> - -{{#Extra}} -<div id='Field-Extra-Front'> -<HR> -<span class='header'><u>Note</u>: Additional Information is Available</span></span> -</div> -<div id='Field-Extra'> -<HR> -<span class='header'><u>Additional Info</u>: </span></span> -{{Extra}} -<BR><BR> -</div> -{{/Extra}} - -{{#Tags}} -<div id='Tags'><span class='header'><u>Tags</u>: </span>{{Tags}}</div> -{{/Tags}} - -{{#See_Also}} -<div id='Field-See_Also'> -<HR> -{{See_Also}} -</div> -{{/See_Also}} - -{{#TOC}} -<div id='Field-TOC'> -<BR><HR> -{{TOC}} -</div> -{{/TOC}} - -{{#Outline}} -<div id='Field-Outline'> -<BR><HR> -{{Outline}} -</div> -{{/Outline}} - - -</div> -</div> -</div> -</div> - -<script> - function setElementDisplay(id,show) { - el = document.getElementById(id) - if (el == null) { return; } - // Assuming if display is not set, it is set to none by CSS - if (show === 0) { show = (el.style.display == 'none' || el.style.display == ''); } - el.style.display = (show ? 'block' : 'none') - } - function hideElement(id) { - setElementDisplay(id, false); - } - function showElement(id) { - setElementDisplay(id, true); - } - function toggleElement(id) { - setElementDisplay(id, 0); - } - - function scrollToElement(id, show) { - setElementDisplay(id, show); - el = document.getElementById(id) - if (el == null) { return; } - window.scroll(0,findPos(el)); - } - - function scrollToElementToggle(id) { - scrollToElement(id, 0); - } - - function scrollToElementVisible(id) { - scrollToElement(id, true); - } - -//Finds y value of given object -function findPos(obj) { - var curtop = 0; - if (obj.offsetParent) { - do { - curtop += obj.offsetTop; - } while (obj = obj.offsetParent); - return [curtop]; - } -} -</script> \ No newline at end of file diff --git a/anknotes/extra/ancillary/FrontTemplate.htm b/anknotes/extra/ancillary/FrontTemplate.htm index 0631843..25725df 100644 --- a/anknotes/extra/ancillary/FrontTemplate.htm +++ b/anknotes/extra/ancillary/FrontTemplate.htm @@ -2,12 +2,14 @@ <div id='Card-{{Card}}'> <div id='Deck-{{Deck}}'> <div id='Side-Front'> -<div id='Header'> -<header id='Header-Avi'><h2> -<div id='Field-%(Title)s-Prompt'>What is the Note's Title???</div> -<div id='Field-%(Title)s'>{{%(Title)s}} </div> -</h2></header> -</div> +<section class="header header-avi Field-%(Title)s-Prompt"> + <h2>ANKNOTES</h2> + <div id='Field-%(Title)s-Prompt-New'>What is the Note's Title???</div> +</section> +<section class="header header-avi Field-%(Title)s" id='Header-Field-%(Title)s'> + <h2>ANKNOTES</h2> + <div id='Field-%(Title)s-New'>{{%(Title)s}}</div> +</section> <hr id=answer> {{#%(See Also)s}} @@ -26,7 +28,7 @@ <span class='Field-%(See Also)s'> | </span> {{/%(TOC)s}} <span class='Field-%(Outline)s'> -<a href='javascript:;' onclick='scrollToElementToggle("Field-%(Outline)s")' class='header'>[Outline]</a> +<a href='javascript:;' onclick='scrollToElementToggle("Field-%(Outline)s")' class='header'>(Outline)</a> </span> {{/%(Outline)s}} </div> @@ -83,7 +85,6 @@ </div> - <script> evernote_guid_prefix = '%(Evernote GUID Prefix)s' evernote_uid = '%(Evernote UID)s' @@ -116,7 +117,7 @@ if (el == null) { return; } window.scroll(0,findPos(el)); } - + function scrollToElementToggle(id) { scrollToElement(id, 0); } @@ -136,7 +137,8 @@ } } -document.getElementById('Link-EN-Self').innerHTML = "<a href='" + generateEvernoteLink('{{Evernote GUID}}') + "'>Open in EN</a> <span class='separator'> | </span>" -document.getElementById('Field-Title').innerHTML = "<a href='" + generateEvernoteLink('{{Evernote GUID}}') + "'>" + document.getElementById('Field-Title').innerHTML + "</a>" +document.getElementById('Link-EN-Self').innerHTML = "<a href='" + generateEvernoteLink('{{%(Evernote GUID)s}}') + "'>Open in EN</a> <span class='separator'> | </span>" +document.getElementById('Field-%(Title)s-New').innerHTML = "<span class='link'>" + document.getElementById('Field-%(Title)s-New').innerHTML + "</span>" +document.getElementById('Header-Field-%(Title)s').outerHTML = "<a href='" + generateEvernoteLink('{{%(Evernote GUID)s}}') + "'>" + document.getElementById('Header-Field-%(Title)s').outerHTML + "</a>" -</script> \ No newline at end of file +</script> diff --git a/anknotes/extra/ancillary/_AviAnkiCSS.css b/anknotes/extra/ancillary/_AviAnkiCSS.css index 031fc43..535968c 100644 --- a/anknotes/extra/ancillary/_AviAnkiCSS.css +++ b/anknotes/extra/ancillary/_AviAnkiCSS.css @@ -1,39 +1,88 @@ -/* @import url("_AviAnkiCSS.css") */ +/* @import url("_AviAnkiCSS.css"); */ +@import url(https://fonts.googleapis.com/css?family=Roboto:400,300,700,100); /******************************************************************************************************* Default Card Rules *******************************************************************************************************/ +body { + font-family: Roboto, sans-serif; + background: rgb(44, 61, 81); + padding: 1em; + -webkit-font-smoothing: antialiased; +} + .card { - font-family: Tahoma; - font-size: 20px; - text-align: left; - color: black; - background-color: white; -} + font-size: 20px; + text-align: left; + background-color: white; + color: black; +} /******************************************************************************************************* Header rectangles, which sit at the top of every single card *******************************************************************************************************/ -header { -background: #000; -width:100%; -text-align: center; -/* border:3px #990000 solid; */ +/* Positioning: Vertical Align: Middle */ + +section.header { + display: block; + width: 100%; + height: 200px; + position: relative; + text-align: center; + -webkit-transform-style: preserve-3d; + -moz-transform-style: preserve-3d; + transform-style: preserve-3d; +} + +section.header div { + padding: 1em; + margin: 0px; + position: relative; + top: 50%; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); +} + +/* Other Styling */ + +section.header { + background: rgb(231, 76, 60); + margin: 0px; + border-radius: .2em; + /* border:3px #990000 solid; */ +} + +/******************************************************************************************************* + ANKNOTES Top-Right call out +*******************************************************************************************************/ + +section.header h2 { + text-transform: uppercase; + margin: 0; + font-size: 16px; + position: absolute; + top: 5px; + right: 5px; + font-weight: bold; + /* color: rgb(236, 240, 241); */ + color: rgb(44, 61, 81); + /* border:3px #990000 solid; */ } -header h2 { -color: #fff; -text-shadow: 1px 1px 2px rgba(0,0,0,0.2); +section.header.header-avi h2, section.header.header-bluewhitered h2 { +color: rgb(138, 16, 16); } /******************************************************************************************************* TITLE Fields *******************************************************************************************************/ -.card #Field-Title, .card #Field-Title-Prompt { +.card section.header.Field-Title, .card section.header.Field-Title-Prompt { text-align: center; + font-family: Tahoma; font-weight: bold; font-size: 72px; font-variant: small-caps; @@ -41,31 +90,23 @@ text-shadow: 1px 1px 2px rgba(0,0,0,0.2); /* color: #A40F2D; */ } - -.card #Header a { +.card section.header a { text-decoration: none; } -.card #Header:hover #Field-Title span.link, .card #Header:hover #Field-Title-Prompt span.link { +.card section.header:hover #Field-Title-New span.link, .card section.header:hover #Field-Title-Prompt-New span.link { border-bottom: none; } -.card #Field-Title-Prompt { - width: 100% ; - position: fixed; - font-size: 48px; +.card section.header.Field-Title-Prompt { color: #a90030; } -.card #Header #Field-Title span.link, .card #Header #Field-Title-Prompt span.link { - border-bottom: 5px black solid; -} - -.card #Header #Field-Title-Prompt span.link { +.card section.header #Field-Title-Prompt-New span.link { border-bottom-color: #a90030; } - .card a:hover #Field-Title-Prompt { +.card a:hover #Field-Title-Prompt-New { color: rgb(210, 13, 13); } @@ -73,94 +114,94 @@ text-shadow: 1px 1px 2px rgba(0,0,0,0.2); Header bars with custom gradient backgrounds *******************************************************************************************************/ - -#Header-BlueWhiteRed, #Header-Avi h2, -#Header-Avi-BlueRed h2, -#Header-RedOrange h2 +section.header { -text-shadow: 1px 1px 2px rgba(0,0,0,0.2); -text-shadow: black 0 1px; + text-shadow: 1px 1px 2px rgba(0,0,0,0.2); + text-shadow: black 0 1px; } -#Header-RedOrange h2, #Header-RedOrange h2 { -color:#990000; +section.header.header-redorange { + color:#990000; } -a:hover #Header-RedOrange h2 { +a:hover section.header.header-redorange { color: rgb(106, 6, 6); } -.card #Header-RedOrange #Field-Title span.link { - border-bottom-color:#990000; +.card section.header.header-redorange.Field-Title span.link { + border-bottom-color:#990000; } -#Header-RedOrange { -/* Background gradient code */ -background: -moz-linear-gradient(left, #ff1a00 0%, #fff200 36%, #fff200 58%, #ff1a00 100%); /* FF3.6+ */ -background: -webkit-gradient(linear, left top, right top, color-stop(0%,#ff1a00), color-stop(36%,#fff200), color-stop(58%,#fff200), color-stop(100%,#ff1a00)); /* Chrome,Safari4+ */ -background: -webkit-linear-gradient(left, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* Chrome10+,Safari5.1+ */ -background: -o-linear-gradient(left, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* Opera 11.10+ */ -background: -ms-linear-gradient(left, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* IE10+ */ -background: linear-gradient(to right, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* W3C */ -filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ff1a00', endColorstr='#ff1a00',GradientType=1 ); /* IE6-9 */ -/* z-index: 100; /* the stack order: foreground */ -/* border-bottom:1px #990000 solid; */ -border:3px #990000 solid; +section.header.header-redorange { + /* Background gradient code */ + background: -moz-linear-gradient(left, #ff1a00 0%, #fff200 36%, #fff200 58%, #ff1a00 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, right top, color-stop(0%,#ff1a00), color-stop(36%,#fff200), color-stop(58%,#fff200), color-stop(100%,#ff1a00)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(left, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(left, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(left, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* IE10+ */ + background: linear-gradient(to right, #ff1a00 0%,#fff200 36%,#fff200 58%,#ff1a00 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ff1a00', endColorstr='#ff1a00',GradientType=1 ); /* IE6-9 */ + /* z-index: 100; /* the stack order: foreground */ + /* border-bottom:1px #990000 solid; */ + border:3px #990000 solid; } - -#Header-BlueWhiteRed h2, #Header-Avi h2 { -color:#004C99; +section.header.header-bluewhitered, +section.header.header-avi { + color:#004C99; } -a:hover #Header-BlueWhiteRed h2, a:hover #Header-Avi h2 { +a:hover section.header.header-bluewhitered, +a:hover section.header.header-avi { color: rgb(10, 121, 243); } -.card #Header-BlueWhiteRed #Field-Title span.link, .card #Header-Avi #Field-Title span.link { +.card section.header.header-bluewhitered.Field-Title span.link, +.card section.header.header-avi.Field-Title span.link { border-bottom-color:#004C99; } -#Header-BlueWhiteRed, #Header-Avi { +section.header.header-bluewhitered, +section.header.header-avi { /* Background gradient code */ -background: #3b679e; /* Old browsers */ -background: -moz-linear-gradient(left, #3b679e 0%, #ffffff 38%, #ffffff 59%, #ff1111 100%); /* FF3.6+ */ -background: -webkit-gradient(linear, left top, right top, color-stop(0%,#3b679e), color-stop(38%,#ffffff), color-stop(59%,#ffffff), color-stop(100%,#ff1111)); /* Chrome,Safari4+ */ -background: -webkit-linear-gradient(left, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* Chrome10+,Safari5.1+ */ -background: -o-linear-gradient(left, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* Opera 11.10+ */ -background: -ms-linear-gradient(left, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* IE10+ */ -background: linear-gradient(to right, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* W3C */ -filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3b679e', endColorstr='#ff1111',GradientType=1 ); /* IE6-9 */ -/* z-index: 100; /* the stack order: foreground */ -/* border-bottom:1px #004C99 solid; */ -border:3px #990000 solid; -} - -#Header-Avi-BlueRed h2, #Header-Avi-BlueRed h2 { + background: #3b679e; /* Old browsers */ + background: -moz-linear-gradient(left, #3b679e 0%, #ffffff 38%, #ffffff 59%, #ff1111 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, right top, color-stop(0%,#3b679e), color-stop(38%,#ffffff), color-stop(59%,#ffffff), color-stop(100%,#ff1111)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(left, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(left, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(left, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* IE10+ */ + background: linear-gradient(to right, #3b679e 0%,#ffffff 38%,#ffffff 59%,#ff1111 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3b679e', endColorstr='#ff1111',GradientType=1 ); /* IE6-9 */ + /* z-index: 100; /* the stack order: foreground */ + /* border-bottom:1px #004C99 solid; */ + border:3px #990000 solid; +} + +section.header.header-avi-bluered { color:#80A6CC; } -a:hover #Header-Avi-BlueRed h2 { +a:hover section.header.header-avi-bluered { color: rgb(241, 135, 154); } -.card #Header-BlueRed #Field-Title span.link { +.card section.header.header-avi-bluered.Field-Title span.link { border-bottom-color:#80A6CC; } -#Header-Avi-BlueRed { +section.header.header-avi-bluered { /* Background gradient code */ -background: -moz-linear-gradient(left, #bf0060 0%, #0060bf 36%, #0060bf 58%, #bf0060 100%); /* FF3.6+ */ -background: -webkit-gradient(linear, left top, right top, color-stop(0%,#bf0060), color-stop(36%,#0060bf), color-stop(58%,#0060bf), color-stop(100%,#bf0060)); /* Chrome,Safari4+ */ -background: -webkit-linear-gradient(left, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* Chrome10+,Safari5.1+ */ -background: -o-linear-gradient(left, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* Opera 11.10+ */ -background: -ms-linear-gradient(left, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* IE10+ */ -background: linear-gradient(to right, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* W3C */ -filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#0060bf', endColorstr='#bf0060',GradientType=1 ); /* IE6-9 */ -/* z-index: 100; /* the stack order: foreground */ -/* border-bottom:1px #004C99 solid; */ -border:3px #004C99 solid; + background: -moz-linear-gradient(left, #bf0060 0%, #0060bf 36%, #0060bf 58%, #bf0060 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, right top, color-stop(0%,#bf0060), color-stop(36%,#0060bf), color-stop(58%,#0060bf), color-stop(100%,#bf0060)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(left, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(left, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(left, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* IE10+ */ + background: linear-gradient(to right, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#0060bf', endColorstr='#bf0060',GradientType=1 ); /* IE6-9 */ + /* z-index: 100; /* the stack order: foreground */ + /* border-bottom:1px #004C99 solid; */ + border:3px #004C99 solid; } /******************************************************************************************************* @@ -305,7 +346,17 @@ a:hover { *******************************************************************************************************/ .card .occluded { - color: #444; + color: #555; +} +.card div.occluded { + display: inline-block; + margin: 0px; padding: 0px; +} +.card div.occluded :first-child { + margin-top: 0px; +} +.card div.occluded :last-child { + margin-bottom: 0px; } .card .See_Also, .card .Field-See_Also, .card .separator { @@ -318,29 +369,29 @@ a:hover { *******************************************************************************************************/ -.card #Field-Cloze-Content, -.card #Field-Title-Prompt, -.card #Side-Front #Footer-Line, -.card #Side-Front .occluded, -.card #Side-Front #Field-See_Also, +.card #Field-Cloze-Content, +.card section.header.Field-Title-Prompt, +.card #Side-Front #Footer-Line, +.card #Side-Front #Field-See_Also, .card #Field-TOC, .card #Field-Outline, -.card #Side-Front #Field-Extra, +.card #Side-Front #Field-Extra, .card #Side-Back #Field-Extra-Front, -.card #Card-EvernoteReviewCloze #Field-Content +.card #Card-EvernoteReviewCloze #Field-Content, +.card #Card-EvernoteReviewReversed #Side-Front section.header.Field-Title { display: none; } -.card #Side-Front #Header-Links, -.card #Card-EvernoteReview #Side-Front #Field-Content, -.card #Card-EvernoteReviewReversed #Side-Front #Field-Title +.card #Side-Front #Header-Links, +.card #Card-EvernoteReview #Side-Front #Field-Content, +.card #Side-Front .occluded { visibility: hidden; } -.card #Card-EvernoteReviewCloze #Field-Cloze-Content, -.card #Card-EvernoteReviewReversed #Side-Front #Field-Title-Prompt, +.card #Card-EvernoteReviewCloze #Field-Cloze-Content, +.card #Card-EvernoteReviewReversed #Side-Front section.header.Field-Title-Prompt, .card #Side-Back #Field-See_Also { display: block; diff --git a/anknotes/extra/dev/restart_anki_automate.bat b/anknotes/extra/dev/restart_anki_automate.bat new file mode 100644 index 0000000..3c341a2 --- /dev/null +++ b/anknotes/extra/dev/restart_anki_automate.bat @@ -0,0 +1,4 @@ +cd /d "%~dp0" +rename anknotes.developer.automate2 anknotes.developer.automate +taskkill /f /im anki.exe +"C:\Program Files (x86)\Anki\anki.exe" \ No newline at end of file diff --git a/anknotes/menu.py b/anknotes/menu.py index d008234..828a582 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -116,7 +116,6 @@ def import_from_evernote_manual_metadata(guids=None): guids = find_evernote_guids(file(ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES, 'r').read()) log("Manually downloading %d Notes" % len(guids)) controller = Controller() - controller.evernote.initialize_note_store() controller.forceAutoPage = True controller.currentPage = 1 controller.ManualGUIDs = guids @@ -125,7 +124,6 @@ def import_from_evernote_manual_metadata(guids=None): def import_from_evernote(auto_page_callback=None): controller = Controller() - controller.evernote.initialize_note_store() controller.auto_page_callback = auto_page_callback if auto_page_callback: controller.forceAutoPage = True @@ -300,7 +298,6 @@ def see_also(steps=None, showAlerts=None, validationComplete=False): def update_ancillary_data(): controller = Controller() - controller.evernote.initialize_note_store() controller.update_ancillary_data() diff --git a/anknotes/shared.py b/anknotes/shared.py index 0508f65..f1b57d5 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -4,6 +4,7 @@ from pysqlite2 import dbapi2 as sqlite except ImportError: from sqlite3 import dbapi2 as sqlite +import os ### Check if in Anki try: @@ -25,7 +26,7 @@ from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ EDAMNotFoundException except: - pass + pass # log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') def get_friendly_interval_string(lastImport): diff --git a/remove_tags.py b/remove_tags.py new file mode 100644 index 0000000..3732b00 --- /dev/null +++ b/remove_tags.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +try: + from pysqlite2 import dbapi2 as sqlite +except ImportError: + from sqlite3 import dbapi2 as sqlite + +from anknotes.shared import * + +Error = sqlite.Error +ankDBSetLocal() + +tags = ',#Imported,#Anki_Import,#Anki_Import_High_Priority,' +# ankDB().setrowfactory() +dbRows = ankDB().all("SELECT * FROM %s WHERE ? LIKE '%%,' || name || ',%%' " % TABLES.EVERNOTE.TAGS, tags) + +for dbRow in dbRows: + ankDB().execute("UPDATE %s SET tagNames = REPLACE(tagNames, ',%s,', ','), tagGuids = REPLACE(tagGuids, ',%s,', ',') WHERE tagGuids LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, dbRow['name'], dbRow['guid'],dbRow['guid'] )) +ankDB().commit() \ No newline at end of file From 6f39d9a5350e26d01bb8ca1680b13f35b37ddf86 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Sat, 26 Sep 2015 13:33:47 -0400 Subject: [PATCH 23/70] Add counter --- .gitignore | 1 + anknotes/Anki.py | 1130 ++++++++------- anknotes/AnkiNotePrototype.py | 939 ++++++------ anknotes/Controller.py | 437 +++--- anknotes/EvernoteImporter.py | 509 +++---- anknotes/EvernoteNoteFetcher.py | 303 ++-- anknotes/EvernoteNotePrototype.py | 257 ++-- anknotes/EvernoteNoteTitle.py | 436 +++--- anknotes/EvernoteNotes.py | 804 +++++------ anknotes/__main__.py | 284 +++- anknotes/_re.py | 468 +++--- anknotes/addict/__init__.py | 9 + anknotes/addict/addict.py | 249 ++++ anknotes/ankEvernote.py | 934 ++++++------ anknotes/bare.py | 233 --- anknotes/constants.py | 305 ++-- anknotes/counters.py | 173 +++ anknotes/db.py | 324 +++-- anknotes/detect_see_also_changes.py | 275 ++++ anknotes/enums.py | 136 +- anknotes/error.py | 82 +- anknotes/extra/ancillary/enml2.dtd | 473 +++++- anknotes/extra/ancillary/regex-see_also.txt | 10 +- anknotes/extra/dev/invisible.vbs | 7 +- anknotes/find_deleted_notes.py | 250 ++-- anknotes/graphics.py | 16 +- anknotes/html.py | 396 ++--- anknotes/logging.py | 843 +++++------ anknotes/menu.py | 472 +++--- anknotes/settings.py | 1258 ++++++++-------- anknotes/shared.py | 146 +- anknotes/stopwatch/__init__.py | 784 ++++++---- anknotes/structs.py | 1272 +++++++++-------- anknotes/toc.py | 627 ++++---- ... anknotes_start_detect_see_also_changes.py | 4 +- anknotes_start_note_validation.py | 226 +-- 36 files changed, 8244 insertions(+), 6828 deletions(-) create mode 100644 anknotes/addict/__init__.py create mode 100644 anknotes/addict/addict.py delete mode 100644 anknotes/bare.py create mode 100644 anknotes/counters.py create mode 100644 anknotes/detect_see_also_changes.py rename anknotes_start_bare.py => anknotes_start_detect_see_also_changes.py (54%) diff --git a/.gitignore b/.gitignore index 0498aad..c309fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ anknotes/extra/ancillary/FrontTemplate-Processed.htm anknotes/extra/logs/ anknotes/extra/dev/Restart Anki.lnk anknotes/extra/dev/anknotes.developer* +anknotes/extra/dev/auth_tokens.txt anknotes/extra/user/ *.bk diff --git a/anknotes/Anki.py b/anknotes/Anki.py index 5541157..815f049 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -4,14 +4,14 @@ import sys try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite ### Anknotes Imports from anknotes.AnkiNotePrototype import AnkiNotePrototype from anknotes.shared import * - +from anknotes import stopwatch ### Evernote Imports # from evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec # from evernote.edam.type.ttypes import NoteSortOrder, Note @@ -20,571 +20,569 @@ ### Anki Imports try: - import anki - from anki.notes import Note as AnkiNote - import aqt - from aqt import mw + import anki + from anki.notes import Note as AnkiNote + import aqt + from aqt import mw except: pass DEBUG_RAISE_API_ERRORS = False class Anki: - def __init__(self): - self.deck = None - self.templates = None - - @staticmethod - def get_notebook_guid_from_ankdb(evernote_guid): - return ankDB().scalar("SELECT notebookGuid FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, evernote_guid)) - - def get_deck_name_from_evernote_notebook(self, notebookGuid, deck=None): - if not deck: - deck = self.deck if self.deck else "" - if not hasattr(self, 'notebook_data'): - self.notebook_data = {} - if not notebookGuid in self.notebook_data: - # log_error("Unexpected error: Notebook GUID '%s' could not be found in notebook data: %s" % (notebookGuid, str(self.notebook_data))) - notebook = ankDB().first( - "SELECT name, stack FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTEBOOKS, notebookGuid)) - if not notebook: - log_error( - " get_deck_name_from_evernote_notebook FATAL ERROR: UNABLE TO FIND NOTEBOOK '%s'. " % notebookGuid) - return None - # log("Getting notebook info: %s" % str(notebook)) - notebook_name, notebook_stack = notebook - self.notebook_data[notebookGuid] = {"stack": notebook_stack, "name": notebook_name} - notebook = self.notebook_data[notebookGuid] - if notebook['stack']: - deck += u'::' + notebook['stack'] - deck += "::" + notebook['name'] - deck = deck.replace(": ", "::") - if deck[:2] == '::': - deck = deck[2:] - return deck - - def update_evernote_notes(self, evernote_notes, log_update_if_unchanged=True): - """ - Update Notes in Anki Database - :type evernote_notes: list[EvernoteNotePrototype.EvernoteNotePrototype] - :rtype : int - :param evernote_notes: List of EvernoteNote returned from server or local db - :param log_update_if_unchanged: - :return: Count of notes successfully updated - """ - return self.add_evernote_notes(evernote_notes, True, log_update_if_unchanged=log_update_if_unchanged) - - def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchanged=True): - """ - Add Notes to or Update Notes in Anki Database - :param evernote_notes: - :param update: - :param log_update_if_unchanged: - :type evernote_notes: list[EvernoteNotePrototype.EvernoteNotePrototype] - :type update: bool - :return: Count of notes successfully added or updated - """ - count_update = 0 - count = 0 - max_count = len(evernote_notes) - for ankiNote in evernote_notes: - try: - title = ankiNote.Title.FullTitle - content = ankiNote.Content - if isinstance(content, str): - content = unicode(content, 'utf-8') - anki_field_info = { - FIELDS.TITLE: title, - FIELDS.CONTENT: content, - FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + ankiNote.Guid, - FIELDS.UPDATE_SEQUENCE_NUM: str(ankiNote.UpdateSequenceNum), - FIELDS.SEE_ALSO: u'' - } - except: - log_error("Unable to set field info for: Note '%s': '%s'" % (ankiNote.Title, ankiNote.Guid)) - log_dump(ankiNote.Content, " NOTE CONTENTS ") - log_dump(ankiNote.Content.encode('utf-8'), " NOTE CONTENTS ") - raise - baseNote = None - if update: - baseNote = self.get_anki_note_from_evernote_guid(ankiNote.Guid) - if not baseNote: log('Updating note %s: COULD NOT FIND ANKI NOTE ID' % ankiNote.Guid) - anki_note_prototype = AnkiNotePrototype(self, anki_field_info, ankiNote.TagNames, baseNote, - notebookGuid=ankiNote.NotebookGuid, count=count, - count_update=count_update, max_count=max_count) - anki_note_prototype._log_update_if_unchanged_ = log_update_if_unchanged - if update: - debug_fields = anki_note_prototype.Fields.copy() - del debug_fields[FIELDS.CONTENT] - # log_dump(debug_fields, - # "- > UPDATE_evernote_notes → ADD_evernote_notes: anki_note_prototype: FIELDS ") - if anki_note_prototype.update_note(): count_update += 1 - else: - if not -1 == anki_note_prototype.add_note(): count_update += 1 - count += 1 - return count_update - - def delete_anki_cards(self, evernote_guids): - col = self.collection() - card_ids = [] - for evernote_guid in evernote_guids: - card_ids += mw.col.findCards(FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid) - col.remCards(card_ids) - return len(card_ids) - - @staticmethod - def get_evernote_model_styles(): - if ANKNOTES.IMPORT_MODEL_STYLES_AS_URL: return '@import url("%s");' % ANKNOTES.CSS - return file(os.path.join(ANKNOTES.FOLDER_ANCILLARY, ANKNOTES.CSS), 'r').read() - - def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): - model = mm.byName(modelName) - model_css = self.get_evernote_model_styles() - templates = self.get_templates(modelName==MODELS.EVERNOTE_DEFAULT) - if model and modelName is MODELS.EVERNOTE_DEFAULT: - front = model['tmpls'][0]['qfmt'] - evernote_account_info = get_evernote_account_ids() - if not evernote_account_info.Valid: - info = ankDB().first("SELECT uid, shard, COUNT(uid) as c1, COUNT(shard) as c2 from %s GROUP BY uid, shard ORDER BY c1 DESC, c2 DESC LIMIT 1" % TABLES.SEE_ALSO) - if info and evernote_account_info.update(info[0], info[1]): forceRebuild = True - if evernote_account_info.Valid: - if not "evernote_uid = '%s'" % evernote_account_info.uid in front or not "evernote_shard = '%s'" % evernote_account_info.shard in front: forceRebuild = True - if model['css'] != model_css: forceRebuild = True - if model['tmpls'][0]['qfmt'] != templates['Front']: forceRebuild = True - if not model or forceRebuild: - if model: - for t in model['tmpls']: - t['qfmt'] = templates['Front'] - t['afmt'] = templates['Back'] - model['css'] = model_css - mm.update(model) - else: - model = mm.new(modelName) - - # Add Field for Evernote GUID: - # Note that this field is first because Anki requires the first field to be unique - evernote_guid_field = mm.newField(FIELDS.EVERNOTE_GUID) - evernote_guid_field['sticky'] = True - evernote_guid_field['font'] = 'Consolas' - evernote_guid_field['size'] = 10 - mm.addField(model, evernote_guid_field) - - # Add Standard Fields: - mm.addField(model, mm.newField(FIELDS.TITLE)) - - evernote_content_field = mm.newField(FIELDS.CONTENT) - evernote_content_field['size'] = 14 - mm.addField(model, evernote_content_field) - - evernote_see_also_field = mm.newField(FIELDS.SEE_ALSO) - evernote_see_also_field['size'] = 14 - mm.addField(model, evernote_see_also_field) - - evernote_extra_field = mm.newField(FIELDS.EXTRA) - evernote_extra_field['size'] = 12 - mm.addField(model, evernote_extra_field) - - evernote_toc_field = mm.newField(FIELDS.TOC) - evernote_toc_field['size'] = 10 - mm.addField(model, evernote_toc_field) - - evernote_outline_field = mm.newField(FIELDS.OUTLINE) - evernote_outline_field['size'] = 10 - mm.addField(model, evernote_outline_field) - - # Add USN to keep track of changes vs Evernote's servers - evernote_usn_field = mm.newField(FIELDS.UPDATE_SEQUENCE_NUM) - evernote_usn_field['font'] = 'Consolas' - evernote_usn_field['size'] = 10 - mm.addField(model, evernote_usn_field) - - # Add Templates - - if modelName is MODELS.EVERNOTE_DEFAULT or modelName is MODELS.EVERNOTE_REVERSIBLE: - # Add Default Template - default_template = mm.newTemplate(TEMPLATES.EVERNOTE_DEFAULT) - default_template['qfmt'] = templates['Front'] - default_template['afmt'] = templates['Back'] - mm.addTemplate(model, default_template) - if modelName is MODELS.EVERNOTE_REVERSE_ONLY or modelName is MODELS.EVERNOTE_REVERSIBLE: - # Add Reversed Template - reversed_template = mm.newTemplate(TEMPLATES.EVERNOTE_REVERSED) - reversed_template['qfmt'] = templates['Front'] - reversed_template['afmt'] = templates['Back'] - mm.addTemplate(model, reversed_template) - if modelName is MODELS.EVERNOTE_CLOZE: - # Add Cloze Template - cloze_template = mm.newTemplate(TEMPLATES.EVERNOTE_CLOZE) - cloze_template['qfmt'] = templates['Front'] - cloze_template['afmt'] = templates['Back'] - mm.addTemplate(model, cloze_template) - - # Update Sort field to Title (By default set to GUID since it is the first field) - model['sortf'] = 1 - - # Update Model CSS - model['css'] = model_css - - # Set Type to Cloze - if cloze: - model['type'] = MODELS.TYPE_CLOZE - - # Add Model to Collection - mm.add(model) - - # Add Model id to list - self.evernoteModels[modelName] = model['id'] - return forceRebuild - - def get_templates(self, forceRebuild=False): - if not self.templates or forceRebuild: - evernote_account_info = get_evernote_account_ids() - field_names = { - "Title": FIELDS.TITLE, "Content": FIELDS.CONTENT, "Extra": FIELDS.EXTRA, - "See Also": FIELDS.SEE_ALSO, "TOC": FIELDS.TOC, "Outline": FIELDS.OUTLINE, - "Evernote GUID Prefix": FIELDS.EVERNOTE_GUID_PREFIX, "Evernote GUID": FIELDS.EVERNOTE_GUID, - "Evernote UID": evernote_account_info.uid, "Evernote shard": evernote_account_info.shard - } - # Generate Front and Back Templates from HTML Template in anknotes' addon directory - self.templates = {"Front": file(ANKNOTES.TEMPLATE_FRONT, 'r').read() % field_names} - self.templates["Back"] = self.templates["Front"].replace("<div id='Side-Front'>", "<div id='Side-Back'>") - return self.templates - - def add_evernote_models(self): - col = self.collection() - mm = col.models - self.evernoteModels = {} - - forceRebuild = self.add_evernote_model(mm, MODELS.EVERNOTE_DEFAULT) - self.add_evernote_model(mm, MODELS.EVERNOTE_REVERSE_ONLY, forceRebuild) - self.add_evernote_model(mm, MODELS.EVERNOTE_REVERSIBLE, forceRebuild) - self.add_evernote_model(mm, MODELS.EVERNOTE_CLOZE, forceRebuild, True) - - def setup_ancillary_files(self): - # Copy CSS file from anknotes addon directory to media directory - media_dir = re.sub("(?i)\.(anki2)$", ".media", self.collection().path) - if isinstance(media_dir, str): - media_dir = unicode(media_dir, sys.getfilesystemencoding()) - shutil.copy2(os.path.join(ANKNOTES.FOLDER_ANCILLARY, ANKNOTES.CSS), os.path.join(media_dir, ANKNOTES.CSS)) - - def get_anki_fields_from_anki_note_id(self, a_id, fields_to_ignore=list()): - note = self.collection().getNote(a_id) - try: - items = note.items() - except: - log_error("Unable to get note items for Note ID: %d" % a_id) - raise - return get_dict_from_list(items, fields_to_ignore) - - def get_evernote_guids_from_anki_note_ids(self, ids=None): - if ids is None: - ids = self.get_anknotes_note_ids() - evernote_guids = [] - self.usns = {} - for a_id in ids: - fields = self.get_anki_fields_from_anki_note_id(a_id, [FIELDS.CONTENT]) - evernote_guid = get_evernote_guid_from_anki_fields(fields) - if not evernote_guid: continue - evernote_guids.append(evernote_guid) - log('Anki USN for Note %s is %s' % (evernote_guid, fields[FIELDS.UPDATE_SEQUENCE_NUM]), 'anki-usn') - if FIELDS.UPDATE_SEQUENCE_NUM in fields: - self.usns[evernote_guid] = fields[FIELDS.UPDATE_SEQUENCE_NUM] - else: - log(" ! get_evernote_guids_from_anki_note_ids: Note '%s' is missing USN!" % evernote_guid) - return evernote_guids - - def get_evernote_guids_and_anki_fields_from_anki_note_ids(self, ids=None): - if ids is None: - ids = self.get_anknotes_note_ids() - evernote_guids = {} - for a_id in ids: - fields = self.get_anki_fields_from_anki_note_id(a_id) - evernote_guid = get_evernote_guid_from_anki_fields(fields) - if evernote_guid: evernote_guids[evernote_guid] = fields - return evernote_guids - - def search_evernote_models_query(self): - query = "" - delimiter = "" - for mName, mid in self.evernoteModels.items(): - query += delimiter + "mid:" + str(mid) - delimiter = " OR " - return query - - def get_anknotes_note_ids(self, query_filter=""): - query = self.search_evernote_models_query() - if query_filter: - query = query_filter + " (%s)" % query - ids = self.collection().findNotes(query) - return ids - - def get_anki_note_from_evernote_guid(self, evernote_guid): - col = self.collection() - ids = col.findNotes(FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid) - # TODO: Ugly work around for a bug. Fix this later - if not ids: return None - if not ids[0]: return None - note = AnkiNote(col, None, ids[0]) - return note - - def get_anknotes_note_ids_by_tag(self, tag): - return self.get_anknotes_note_ids("tag:" + tag) - - def get_anknotes_note_ids_with_unadded_see_also(self): - return self.get_anknotes_note_ids('"See Also" "See_Also:"') - - def process_see_also_content(self, anki_note_ids): - count = 0 - count_update = 0 - max_count = len(anki_note_ids) - for a_id in anki_note_ids: - ankiNote = self.collection().getNote(a_id) - try: - items = ankiNote.items() - except: - log_error("Unable to get note items for Note ID: %d" % a_id) - raise - fields = {} - for key, value in items: - fields[key] = value - if not fields[FIELDS.SEE_ALSO]: - anki_note_prototype = AnkiNotePrototype(self, fields, ankiNote.tags, ankiNote, count=count, - count_update=count_update, max_count=max_count) - if anki_note_prototype.Fields[FIELDS.SEE_ALSO]: - log("Detected see also contents for Note '%s': %s" % ( - get_evernote_guid_from_anki_fields(fields), fields[FIELDS.TITLE])) - log(u" ::: %s " % strip_tags_and_new_lines(fields[FIELDS.SEE_ALSO])) - if anki_note_prototype.update_note(): - count_update += 1 - count += 1 - - def process_toc_and_outlines(self): - self.extract_links_from_toc() - self.insert_toc_into_see_also() - self.insert_toc_and_outline_contents_into_notes() - - def update_evernote_note_contents(self): - see_also_notes = ankDB().all("SELECT DISTINCT target_evernote_guid FROM %s WHERE 1" % TABLES.SEE_ALSO) - - - def insert_toc_into_see_also(self): - log = Logger() - db = ankDB() - db._db.row_factory = None - # db._db.row_factory = lambda cursor, row: showInfo(str(row)) - results = db.all( - "SELECT s.source_evernote_guid, s.target_evernote_guid, n.title FROM %s as s, %s as n WHERE n.guid = s.target_evernote_guid AND s.source_evernote_guid != s.target_evernote_guid AND s.from_toc == 1 ORDER BY s.source_evernote_guid ASC, n.title ASC" % ( - TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES)) - grouped_results = {} - # log(' INSERT TOCS INTO ANKI NOTES ', 'dump-insert_toc', timestamp=False, clear=True) - # log('------------------------------------------------', 'dump-insert_toc', timestamp=False) - log.add(' <h1>INSERT TOC LINKS INTO ANKI NOTES</h1> <HR><BR><BR>', 'see_also', timestamp=False, clear=True, - extension='htm') - toc_titles = {} - for row in results: - key = row[0] - value = row[1] - toc_titles[value] = row[2] - if not key in grouped_results: grouped_results[key] = [] - grouped_results[key].append(value) - # log_dump(grouped_results, 'grouped_results', 'insert_toc', timestamp=False) - toc_separator = generate_evernote_span(u' | ', u'Links', u'See Also', bold=False) - count = 0 - count_update = 0 - max_count = len(grouped_results) - - for source_guid, toc_guids in grouped_results.items(): - ankiNote = self.get_anki_note_from_evernote_guid(source_guid) - if not ankiNote: - log.dump(toc_guids, 'Missing Anki Note for ' + source_guid, 'insert_toc', timestamp=False) - else: - fields = get_dict_from_list(ankiNote.items()) - see_also_html = fields[FIELDS.SEE_ALSO] - see_also_links = find_evernote_links_as_guids(see_also_html) - new_tocs = set(toc_guids) - set(see_also_links) - new_toc_count = len(new_tocs) - see_also_count = len(see_also_links) - if new_toc_count > 0: - has_ol = u'<ol' in see_also_html - has_ul = u'<ul' in see_also_html - has_list = has_ol or has_ul - see_also_new = " " - flat_links = (new_toc_count + see_also_count < 3 and not has_list) - toc_delimiter = u' ' if see_also_count is 0 else toc_separator - for toc_guid in toc_guids: - toc_title = toc_titles[toc_guid] - if flat_links: - toc_title = u'[%s]' % toc_title - toc_link = generate_evernote_link(toc_guid, toc_title, value='TOC') - see_also_new += (toc_delimiter + toc_link) if flat_links else (u'\n<li>%s</li>' % toc_link) - toc_delimiter = toc_separator - if flat_links: - find_div_end = see_also_html.rfind('</div>') - if find_div_end > -1: - log.blank() - log.plain('Inserting Flat Links at position %d:' % find_div_end) - log.plain(see_also_html[:find_div_end]) - log.plain(see_also_html[find_div_end:]) - see_also_html = see_also_html[:find_div_end] + see_also_new + '\n' + see_also_html[ - find_div_end:] - see_also_new = '' - else: - see_also_toc_headers = {'ol': u'<br><div style="margin-top:5px;">\n%s</div><ol style="margin-top:3px;">' % generate_evernote_span( - '<u>TABLE OF CONTENTS</u>:', 'Levels', 'Auto TOC', escape=False)} - see_also_toc_headers['ul'] = see_also_toc_headers['ol'].replace('<ol ', '<ul ') - - if see_also_toc_headers['ul'] in see_also_html: - find_ul_end = see_also_html.rfind('</ul>') - see_also_html = see_also_html[:find_ul_end] + '</ol>' + see_also_html[find_ul_end + 5:] - see_also_html = see_also_html.replace(see_also_toc_headers['ul'], see_also_toc_headers['ol']) - if see_also_toc_headers['ol'] in see_also_html: - find_ol_end = see_also_html.rfind('</ol>') - see_also_html = see_also_html[:find_ol_end] + see_also_new + '\n' + see_also_html[find_ol_end:] - see_also_new = '' - else: - header_type = 'ul' if new_toc_count is 1 else 'ul' - see_also_new = see_also_toc_headers[header_type] + u'%s\n</%s>' % (see_also_new, header_type) - if see_also_count == 0: - see_also_html = generate_evernote_span(u'See Also:', 'Links', 'See Also') - see_also_html += see_also_new - see_also_html = see_also_html.replace('<ol>', '<ol style="margin-top:3px;">') - log.add('<h3>%s</h3><br>' % generate_evernote_span(fields[FIELDS.TITLE], 'Links', - 'TOC') + see_also_html + u'<HR>', '_see_also', - timestamp=False, extension='htm') - fields[FIELDS.SEE_ALSO] = see_also_html.replace('evernote:///', 'evernote://') - anki_note_prototype = AnkiNotePrototype(self, fields, ankiNote.tags, ankiNote, count=count, - count_update=count_update, max_count=max_count) - anki_note_prototype._log_update_if_unchanged_ = (new_toc_count > 0) - if anki_note_prototype.update_note(): - count_update += 1 - count += 1 - db._db.row_factory = sqlite.Row - - def extract_links_from_toc(self): - toc_anki_ids = self.get_anknotes_note_ids_by_tag(EVERNOTE.TAG.TOC) - toc_evernote_guids = self.get_evernote_guids_and_anki_fields_from_anki_note_ids(toc_anki_ids) - query_update_toc_links = "UPDATE %s SET is_toc = 1 WHERE " % TABLES.SEE_ALSO - delimiter = "" - for toc_evernote_guid, fields in toc_evernote_guids.items(): - for enLink in find_evernote_links(fields[FIELDS.CONTENT]): - target_evernote_guid = enLink.Guid - link_number = 1 + ankDB().scalar("select COUNT(*) from %s WHERE source_evernote_guid = '%s' " % ( - TABLES.SEE_ALSO, target_evernote_guid)) - toc_link_title = fields[FIELDS.TITLE] - toc_link_html = generate_evernote_span(toc_link_title, 'Links', 'TOC') - query = """INSERT INTO `%s`(`source_evernote_guid`, `number`, `uid`, `shard`, `target_evernote_guid`, `html`, `title`, `from_toc`, `is_toc`) SELECT '%s', %d, %d, '%s', '%s', '%s', '%s', 1, 1 FROM `%s` WHERE NOT EXISTS (SELECT * FROM `%s` WHERE `source_evernote_guid`='%s' AND `target_evernote_guid`='%s') LIMIT 1 """ % ( - TABLES.SEE_ALSO, target_evernote_guid, link_number, enLink.Uid, enLink.Shard, toc_evernote_guid, - toc_link_html.replace(u'\'', u'\'\''), toc_link_title.replace(u'\'', u'\'\''), TABLES.SEE_ALSO, - TABLES.SEE_ALSO, target_evernote_guid, toc_evernote_guid) - log_sql('UPDATE_ANKI_DB: Add See Also Link: SQL Query: ' + query) - ankDB().execute(query) - ankDB().commit() - query_update_toc_links += delimiter + "target_evernote_guid = '%s'" % toc_evernote_guid - delimiter = " OR " - ankDB().execute(query_update_toc_links) - ankDB().commit() - - def insert_toc_and_outline_contents_into_notes(self): - linked_notes_fields = {} - for source_evernote_guid in ankDB().list( - "select DISTINCT source_evernote_guid from %s WHERE is_toc = 1 ORDER BY source_evernote_guid ASC" % TABLES.SEE_ALSO): - note = self.get_anki_note_from_evernote_guid(source_evernote_guid) - if not note: continue - if EVERNOTE.TAG.TOC in note.tags: continue - for fld in note._model['flds']: - if FIELDS.TITLE in fld.get('name'): - note_title = note.fields[fld.get('ord')] - continue - if not note_title: - log_error("Could not find note title for %s for insert_toc_and_outline_contents_into_notes" % note.guid) - continue - note_toc = "" - note_outline = "" - toc_header = "" - outline_header = "" - toc_count = 0 - outline_count = 0 - toc_and_outline_links = ankDB().execute( - "select target_evernote_guid, is_toc, is_outline from %s WHERE source_evernote_guid = '%s' AND (is_toc = 1 OR is_outline = 1) ORDER BY number ASC" % ( - TABLES.SEE_ALSO, source_evernote_guid)) - for target_evernote_guid, is_toc, is_outline in toc_and_outline_links: - if target_evernote_guid in linked_notes_fields: - linked_note_contents = linked_notes_fields[target_evernote_guid][FIELDS.CONTENT] - linked_note_title = linked_notes_fields[target_evernote_guid][FIELDS.TITLE] - else: - linked_note = self.get_anki_note_from_evernote_guid(target_evernote_guid) - if not linked_note: continue - linked_note_contents = u"" - for fld in linked_note._model['flds']: - if FIELDS.CONTENT in fld.get('name'): - linked_note_contents = linked_note.fields[fld.get('ord')] - elif FIELDS.TITLE in fld.get('name'): - linked_note_title = linked_note.fields[fld.get('ord')] - if linked_note_contents: - linked_notes_fields[target_evernote_guid] = { - FIELDS.TITLE: linked_note_title, - FIELDS.CONTENT: linked_note_contents - } - if linked_note_contents: - if isinstance(linked_note_contents, str): - linked_note_contents = unicode(linked_note_contents, 'utf-8') - if (is_toc or is_outline) and (toc_count + outline_count is 0): - log(" > Found TOC/Outline for Note '%s': %s" % (source_evernote_guid, note_title), 'See Also') - if is_toc: - toc_count += 1 - if toc_count is 1: - toc_header = "<span class='header'>TABLE OF CONTENTS</span>: 1. <span class='header'>%s</span>" % linked_note_title - else: - toc_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % ( - toc_count, linked_note_title) - note_toc += "<br><hr>" - - note_toc += linked_note_contents - log(" > Appending TOC #%d contents" % toc_count, 'See Also') - else: - outline_count += 1 - if outline_count is 1: - outline_header = "<span class='header'>OUTLINE</span>: 1. <span class='header'>%s</span>" % linked_note_title - else: - outline_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % ( - outline_count, linked_note_title) - note_outline += "<BR><HR>" - - note_outline += linked_note_contents - log(" > Appending Outline #%d contents" % outline_count, 'See Also') - - if outline_count + toc_count > 0: - if outline_count > 1: - note_outline = "<span class='Outline'>%s</span><BR><BR>" % outline_header + note_outline - if toc_count > 1: - note_toc = "<span class='TOC'>%s</span><BR><BR>" % toc_header + note_toc - for fld in note._model['flds']: - if FIELDS.TOC in fld.get('name'): - note.fields[fld.get('ord')] = note_toc - elif FIELDS.OUTLINE in fld.get('name'): - note.fields[fld.get('ord')] = note_outline - log(" > Flushing Note \r\n", 'See Also') - note.flush() - - def start_editing(self): - self.window().requireReset() - - def stop_editing(self): - if self.collection(): - self.window().maybeReset() - - @staticmethod - def window(): - """ - :rtype : AnkiQt - :return: - """ - return aqt.mw - - def collection(self): - return self.window().col - - def models(self): - return self.collection().models - - def decks(self): - return self.collection().decks + def __init__(self): + self.deck = None + self.templates = None + + @staticmethod + def get_notebook_guid_from_ankdb(evernote_guid): + return ankDB().scalar("SELECT notebookGuid FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, evernote_guid)) + + def get_deck_name_from_evernote_notebook(self, notebookGuid, deck=None): + if not deck: + deck = self.deck if self.deck else "" + if not hasattr(self, 'notebook_data'): + self.notebook_data = {} + if not notebookGuid in self.notebook_data: + # log_error("Unexpected error: Notebook GUID '%s' could not be found in notebook data: %s" % (notebookGuid, str(self.notebook_data))) + notebook = ankDB().first( + "SELECT name, stack FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTEBOOKS, notebookGuid)) + if not notebook: + log_error( + " get_deck_name_from_evernote_notebook FATAL ERROR: UNABLE TO FIND NOTEBOOK '%s'. " % notebookGuid) + return None + # log("Getting notebook info: %s" % str(notebook)) + notebook_name, notebook_stack = notebook + self.notebook_data[notebookGuid] = {"stack": notebook_stack, "name": notebook_name} + notebook = self.notebook_data[notebookGuid] + if notebook['stack']: + deck += u'::' + notebook['stack'] + deck += "::" + notebook['name'] + deck = deck.replace(": ", "::") + if deck[:2] == '::': + deck = deck[2:] + return deck + + def update_evernote_notes(self, evernote_notes, log_update_if_unchanged=True): + """ + Update Notes in Anki Database + :type evernote_notes: list[EvernoteNotePrototype.EvernoteNotePrototype] + :rtype : int + :param evernote_notes: List of EvernoteNote returned from server or local db + :param log_update_if_unchanged: + :return: Count of notes successfully updated + """ + return self.add_evernote_notes(evernote_notes, True, log_update_if_unchanged=log_update_if_unchanged) + + def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchanged=True): + """ + Add Notes to or Update Notes in Anki Database + :param evernote_notes: + :param update: + :param log_update_if_unchanged: + :type evernote_notes: list[EvernoteNotePrototype.EvernoteNotePrototype] + :type update: bool + :return: Count of notes successfully added or updated + """ + count_update = 0 + tmr = stopwatch.Timer(len(evernote_notes), 100, label='AddEvernoteNotes') + if tmr.willReportProgress: + log_banner(['ADDING', 'UPDATING'][update] + " %d EVERNOTE NOTES %s ANKI" % (tmr.max, ['TO', 'IN'][update]), tmr.label, append_newline=False) + for ankiNote in evernote_notes: + try: + title = ankiNote.Title.FullTitle + content = ankiNote.Content + if isinstance(content, str): + content = unicode(content, 'utf-8') + anki_field_info = { + FIELDS.TITLE: title, + FIELDS.CONTENT: content, + FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + ankiNote.Guid, + FIELDS.UPDATE_SEQUENCE_NUM: str(ankiNote.UpdateSequenceNum), + FIELDS.SEE_ALSO: u'' + } + except: + log_error("Unable to set field info for: Note '%s': '%s'" % (ankiNote.Title, ankiNote.Guid)) + log_dump(ankiNote.Content, " NOTE CONTENTS ") + log_dump(ankiNote.Content.encode('utf-8'), " NOTE CONTENTS ") + raise + if tmr.step(): + log(['Adding', 'Updating'][update] + " Note %5s: %s: %s" % ('#' + str(tmr.count), tmr.progress, title), tmr.label) + baseNote = None + if update: + baseNote = self.get_anki_note_from_evernote_guid(ankiNote.Guid) + if not baseNote: log('Updating note %s: COULD NOT FIND ANKI NOTE ID' % ankiNote.Guid) + assert ankiNote.Tags + anki_note_prototype = AnkiNotePrototype(self, anki_field_info, ankiNote.TagNames, baseNote, + notebookGuid=ankiNote.NotebookGuid, count=tmr.count, + count_update=tmr.count_success, max_count=tmr.max) + anki_note_prototype._log_update_if_unchanged_ = log_update_if_unchanged + if (update and anki_note_prototype.update_note()) or (not update and anki_note_prototype.add_note() != -1): tmr.reportSuccess() + return tmr.count_success + + def delete_anki_cards(self, evernote_guids): + col = self.collection() + card_ids = [] + for evernote_guid in evernote_guids: + card_ids += mw.col.findCards(FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid) + col.remCards(card_ids) + return len(card_ids) + + @staticmethod + def get_evernote_model_styles(): + if MODELS.OPTIONS.IMPORT_STYLES: return '@import url("%s");' % FILES.ANCILLARY.CSS + return file(os.path.join(FOLDERS.ANCILLARY, FILES.ANCILLARY.CSS), 'r').read() + + def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): + model = mm.byName(modelName) + model_css = self.get_evernote_model_styles() + templates = self.get_templates(modelName==MODELS.DEFAULT) + if model and modelName is MODELS.DEFAULT: + front = model['tmpls'][0]['qfmt'] + evernote_account_info = get_evernote_account_ids() + if not evernote_account_info.Valid: + info = ankDB().first("SELECT uid, shard, COUNT(uid) as c1, COUNT(shard) as c2 from %s GROUP BY uid, shard ORDER BY c1 DESC, c2 DESC LIMIT 1" % TABLES.SEE_ALSO) + if info and evernote_account_info.update(info[0], info[1]): forceRebuild = True + if evernote_account_info.Valid: + if not "evernote_uid = '%s'" % evernote_account_info.uid in front or not "evernote_shard = '%s'" % evernote_account_info.shard in front: forceRebuild = True + if model['css'] != model_css: forceRebuild = True + if model['tmpls'][0]['qfmt'] != templates['Front']: forceRebuild = True + if not model or forceRebuild: + if model: + for t in model['tmpls']: + t['qfmt'] = templates['Front'] + t['afmt'] = templates['Back'] + model['css'] = model_css + mm.update(model) + else: + model = mm.new(modelName) + + # Add Field for Evernote GUID: + # Note that this field is first because Anki requires the first field to be unique + evernote_guid_field = mm.newField(FIELDS.EVERNOTE_GUID) + evernote_guid_field['sticky'] = True + evernote_guid_field['font'] = 'Consolas' + evernote_guid_field['size'] = 10 + mm.addField(model, evernote_guid_field) + + # Add Standard Fields: + mm.addField(model, mm.newField(FIELDS.TITLE)) + + evernote_content_field = mm.newField(FIELDS.CONTENT) + evernote_content_field['size'] = 14 + mm.addField(model, evernote_content_field) + + evernote_see_also_field = mm.newField(FIELDS.SEE_ALSO) + evernote_see_also_field['size'] = 14 + mm.addField(model, evernote_see_also_field) + + evernote_extra_field = mm.newField(FIELDS.EXTRA) + evernote_extra_field['size'] = 12 + mm.addField(model, evernote_extra_field) + + evernote_toc_field = mm.newField(FIELDS.TOC) + evernote_toc_field['size'] = 10 + mm.addField(model, evernote_toc_field) + + evernote_outline_field = mm.newField(FIELDS.OUTLINE) + evernote_outline_field['size'] = 10 + mm.addField(model, evernote_outline_field) + + # Add USN to keep track of changes vs Evernote's servers + evernote_usn_field = mm.newField(FIELDS.UPDATE_SEQUENCE_NUM) + evernote_usn_field['font'] = 'Consolas' + evernote_usn_field['size'] = 10 + mm.addField(model, evernote_usn_field) + + # Add Templates + + if modelName is MODELS.DEFAULT or modelName is MODELS.REVERSIBLE: + # Add Default Template + default_template = mm.newTemplate(TEMPLATES.DEFAULT) + default_template['qfmt'] = templates['Front'] + default_template['afmt'] = templates['Back'] + mm.addTemplate(model, default_template) + if modelName is MODELS.REVERSE_ONLY or modelName is MODELS.REVERSIBLE: + # Add Reversed Template + reversed_template = mm.newTemplate(TEMPLATES.REVERSED) + reversed_template['qfmt'] = templates['Front'] + reversed_template['afmt'] = templates['Back'] + mm.addTemplate(model, reversed_template) + if modelName is MODELS.CLOZE: + # Add Cloze Template + cloze_template = mm.newTemplate(TEMPLATES.CLOZE) + cloze_template['qfmt'] = templates['Front'] + cloze_template['afmt'] = templates['Back'] + mm.addTemplate(model, cloze_template) + + # Update Sort field to Title (By default set to GUID since it is the first field) + model['sortf'] = 1 + + # Update Model CSS + model['css'] = model_css + + # Set Type to Cloze + if cloze: + model['type'] = MODELS.TYPES.CLOZE + + # Add Model to Collection + mm.add(model) + + # Add Model id to list + self.evernoteModels[modelName] = model['id'] + return forceRebuild + + def get_templates(self, forceRebuild=False): + if not self.templates or forceRebuild: + evernote_account_info = get_evernote_account_ids() + field_names = { + "Title": FIELDS.TITLE, "Content": FIELDS.CONTENT, "Extra": FIELDS.EXTRA, + "See Also": FIELDS.SEE_ALSO, "TOC": FIELDS.TOC, "Outline": FIELDS.OUTLINE, + "Evernote GUID Prefix": FIELDS.EVERNOTE_GUID_PREFIX, "Evernote GUID": FIELDS.EVERNOTE_GUID, + "Evernote UID": evernote_account_info.uid, "Evernote shard": evernote_account_info.shard + } + # Generate Front and Back Templates from HTML Template in anknotes' addon directory + self.templates = {"Front": file(FILES.ANCILLARY.TEMPLATE, 'r').read() % field_names} + self.templates["Back"] = self.templates["Front"].replace("<div id='Side-Front'>", "<div id='Side-Back'>") + return self.templates + + def add_evernote_models(self): + col = self.collection() + mm = col.models + self.evernoteModels = {} + + forceRebuild = self.add_evernote_model(mm, MODELS.DEFAULT) + self.add_evernote_model(mm, MODELS.REVERSE_ONLY, forceRebuild) + self.add_evernote_model(mm, MODELS.REVERSIBLE, forceRebuild) + self.add_evernote_model(mm, MODELS.CLOZE, forceRebuild, True) + + def setup_ancillary_files(self): + # Copy CSS file from anknotes addon directory to media directory + media_dir = re.sub("(?i)\.(anki2)$", ".media", self.collection().path) + if isinstance(media_dir, str): + media_dir = unicode(media_dir, sys.getfilesystemencoding()) + shutil.copy2(os.path.join(FOLDERS.ANCILLARY, FILES.ANCILLARY.CSS), os.path.join(media_dir, FILES.ANCILLARY.CSS)) + + def get_anki_fields_from_anki_note_id(self, a_id, fields_to_ignore=list()): + note = self.collection().getNote(a_id) + try: + items = note.items() + except: + log_error("Unable to get note items for Note ID: %d" % a_id) + raise + return get_dict_from_list(items, fields_to_ignore) + + def get_evernote_guids_from_anki_note_ids(self, ids=None): + if ids is None: + ids = self.get_anknotes_note_ids() + evernote_guids = [] + self.usns = {} + for a_id in ids: + fields = self.get_anki_fields_from_anki_note_id(a_id, [FIELDS.CONTENT]) + evernote_guid = get_evernote_guid_from_anki_fields(fields) + if not evernote_guid: continue + evernote_guids.append(evernote_guid) + log('Anki USN for Note %s is %s' % (evernote_guid, fields[FIELDS.UPDATE_SEQUENCE_NUM]), 'anki-usn') + if FIELDS.UPDATE_SEQUENCE_NUM in fields: + self.usns[evernote_guid] = fields[FIELDS.UPDATE_SEQUENCE_NUM] + else: + log(" ! get_evernote_guids_from_anki_note_ids: Note '%s' is missing USN!" % evernote_guid) + return evernote_guids + + def get_evernote_guids_and_anki_fields_from_anki_note_ids(self, ids=None): + if ids is None: + ids = self.get_anknotes_note_ids() + evernote_guids = {} + for a_id in ids: + fields = self.get_anki_fields_from_anki_note_id(a_id) + evernote_guid = get_evernote_guid_from_anki_fields(fields) + if evernote_guid: evernote_guids[evernote_guid] = fields + return evernote_guids + + def search_evernote_models_query(self): + query = "" + delimiter = "" + for mName, mid in self.evernoteModels.items(): + query += delimiter + "mid:" + str(mid) + delimiter = " OR " + return query + + def get_anknotes_note_ids(self, query_filter=""): + query = self.search_evernote_models_query() + if query_filter: + query = query_filter + " (%s)" % query + ids = self.collection().findNotes(query) + return ids + + def get_anki_note_from_evernote_guid(self, evernote_guid): + col = self.collection() + ids = col.findNotes(FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid) + # TODO: Ugly work around for a bug. Fix this later + if not ids: return None + if not ids[0]: return None + note = AnkiNote(col, None, ids[0]) + return note + + def get_anknotes_note_ids_by_tag(self, tag): + return self.get_anknotes_note_ids("tag:" + tag) + + def get_anknotes_note_ids_with_unadded_see_also(self): + return self.get_anknotes_note_ids('"See Also" "See_Also:"') + + def process_see_also_content(self, anki_note_ids): + count = 0 + count_update = 0 + max_count = len(anki_note_ids) + for a_id in anki_note_ids: + ankiNote = self.collection().getNote(a_id) + try: + items = ankiNote.items() + except: + log_error("Unable to get note items for Note ID: %d" % a_id) + raise + fields = {} + for key, value in items: + fields[key] = value + if not fields[FIELDS.SEE_ALSO]: + anki_note_prototype = AnkiNotePrototype(self, fields, ankiNote.tags, ankiNote, count=count, + count_update=count_update, max_count=max_count) + if anki_note_prototype.Fields[FIELDS.SEE_ALSO]: + log("Detected see also contents for Note '%s': %s" % ( + get_evernote_guid_from_anki_fields(fields), fields[FIELDS.TITLE])) + log(u" ::: %s " % strip_tags_and_new_lines(fields[FIELDS.SEE_ALSO])) + if anki_note_prototype.update_note(): + count_update += 1 + count += 1 + + def process_toc_and_outlines(self): + self.extract_links_from_toc() + self.insert_toc_into_see_also() + self.insert_toc_and_outline_contents_into_notes() + + def update_evernote_note_contents(self): + see_also_notes = ankDB().all("SELECT DISTINCT target_evernote_guid FROM %s WHERE 1" % TABLES.SEE_ALSO) + + + def insert_toc_into_see_also(self): + log = Logger(rm_path=True) + db = ankDB() + db._db.row_factory = None + results = db.all( + "SELECT s.source_evernote_guid, s.target_evernote_guid, n.title, n2.title FROM %s as s, %s as n, %s as n2 WHERE s.source_evernote_guid != s.target_evernote_guid AND n.guid = s.target_evernote_guid AND n2.guid = s.source_evernote_guid AND s.from_toc == 1 ORDER BY s.source_evernote_guid ASC, n.title ASC" % ( + TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES, TABLES.EVERNOTE.NOTES)) + all_guids = [x[0] for x in db.all("SELECT guid FROM %s WHERE tagNames NOT LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, TAGS.TOC))] + grouped_results = {} + + toc_titles = {} + for row in results: + key = row[0] + value = row[1] + if key not in all_guids: continue + toc_titles[value] = row[2] + if key not in grouped_results: grouped_results[key] = [row[3], []] + grouped_results[key][1].append(value) + # log_dump(grouped_results, 'grouped_results', 'insert_toc', timestamp=False) + log.banner('INSERT TOCS INTO ANKI NOTES: %d NOTES' % len(grouped_results), 'insert_toc') + toc_separator = generate_evernote_span(u' | ', u'Links', u'See Also', bold=False) + count = 0 + count_update = 0 + max_count = len(grouped_results) + log.add(' <h1>INSERT TOC LINKS INTO ANKI NOTES: %d TOTAL NOTES</h1> <HR><BR><BR>' % max_count, 'see_also', timestamp=False, clear=True, + extension='htm') + for source_guid, source_guid_info in sorted(grouped_results.items(), key=lambda s: s[1][0]): + toc_guids = source_guid_info[1] + ankiNote = self.get_anki_note_from_evernote_guid(source_guid) + if not ankiNote: + log.dump(toc_guids, 'Missing Anki Note for ' + source_guid, 'insert_toc', timestamp=False) + else: + fields = get_dict_from_list(ankiNote.items()) + see_also_html = fields[FIELDS.SEE_ALSO] + content_links = find_evernote_links_as_guids(fields[FIELDS.CONTENT]) + see_also_links = find_evernote_links_as_guids(see_also_html) + new_tocs = set(toc_guids) - set(see_also_links) - set(content_links) + log.dump([new_tocs, toc_guids, see_also_links, content_links], 'TOCs for %s' % fields[FIELDS.TITLE] + ' vs ' + source_guid_title, 'insert_toc_new_tocs', crosspost_to_default=False) + new_toc_count = len(new_tocs) + if new_toc_count > 0: + see_also_count = len(see_also_links) + has_ol = u'<ol' in see_also_html + has_ul = u'<ul' in see_also_html + has_list = has_ol or has_ul + see_also_new = " " + flat_links = (new_toc_count + see_also_count < 3 and not has_list) + toc_delimiter = u' ' if see_also_count is 0 else toc_separator + for toc_guid in toc_guids: + toc_title = toc_titles[toc_guid] + if flat_links: + toc_title = u'[%s]' % toc_title + toc_link = generate_evernote_link(toc_guid, toc_title, value='TOC') + see_also_new += (toc_delimiter + toc_link) if flat_links else (u'\n<li>%s</li>' % toc_link) + toc_delimiter = toc_separator + if flat_links: + find_div_end = see_also_html.rfind('</div>') + if find_div_end > -1: + see_also_html = see_also_html[:find_div_end] + see_also_new + '\n' + see_also_html[find_div_end:] + see_also_new = '' + else: + see_also_toc_headers = {'ol': u'<br><div style="margin-top:5px;">\n%s</div><ol style="margin-top:3px;">' % generate_evernote_span( + '<u>TABLE OF CONTENTS</u>:', 'Levels', 'Auto TOC', escape=False)} + see_also_toc_headers['ul'] = see_also_toc_headers['ol'].replace('<ol ', '<ul ') + + if see_also_toc_headers['ul'] in see_also_html: + find_ul_end = see_also_html.rfind('</ul>') + see_also_html = see_also_html[:find_ul_end] + '</ol>' + see_also_html[find_ul_end + 5:] + see_also_html = see_also_html.replace(see_also_toc_headers['ul'], see_also_toc_headers['ol']) + if see_also_toc_headers['ol'] in see_also_html: + find_ol_end = see_also_html.rfind('</ol>') + see_also_html = see_also_html[:find_ol_end] + see_also_new + '\n' + see_also_html[find_ol_end:] + see_also_new = '' + else: + header_type = 'ul' if new_toc_count is 1 else 'ol' + see_also_new = see_also_toc_headers[header_type] + u'%s\n</%s>' % (see_also_new, header_type) + if see_also_count == 0: + see_also_html = generate_evernote_span(u'See Also:', 'Links', 'See Also') + see_also_html += see_also_new + see_also_html = see_also_html.replace('<ol>', '<ol style="margin-top:3px;">') + log.add('<h3>%s</h3><br>' % generate_evernote_span(fields[FIELDS.TITLE], 'Links', + 'TOC') + see_also_html + u'<HR>', 'see_also', + timestamp=False, extension='htm') + fields[FIELDS.SEE_ALSO] = see_also_html.replace('evernote:///', 'evernote://') + anki_note_prototype = AnkiNotePrototype(self, fields, ankiNote.tags, ankiNote, count=count, + count_update=count_update, max_count=max_count) + anki_note_prototype._log_update_if_unchanged_ = (new_toc_count > 0) + if anki_note_prototype.update_note(): + count_update += 1 + count += 1 + db._db.row_factory = sqlite.Row + + def extract_links_from_toc(self): + query_update_toc_links = "UPDATE %s SET is_toc = 1 WHERE " % TABLES.SEE_ALSO + delimiter = "" + ankDB().setrowfactory() + toc_entries = ankDB().execute("SELECT * FROM %s WHERE tagNames LIKE '%%,#TOC,%%'" % TABLES.EVERNOTE.NOTES) + for toc_entry in toc_entries: + toc_evernote_guid = toc_entry['guid'] + toc_link_title = toc_entry['title'] + toc_link_html = generate_evernote_span(toc_link_title, 'Links', 'TOC') + for enLink in find_evernote_links(toc_entry['content']): + target_evernote_guid = enLink.Guid + link_number = 1 + ankDB().scalar("select COUNT(*) from %s WHERE source_evernote_guid = '%s' " % ( + TABLES.SEE_ALSO, target_evernote_guid)) + query = """INSERT INTO `%s`(`source_evernote_guid`, `number`, `uid`, `shard`, `target_evernote_guid`, `html`, `title`, `from_toc`, `is_toc`) SELECT '%s', %d, %d, '%s', '%s', '%s', '%s', 1, 1 FROM `%s` WHERE NOT EXISTS (SELECT * FROM `%s` WHERE `source_evernote_guid`='%s' AND `target_evernote_guid`='%s') LIMIT 1 """ % ( + TABLES.SEE_ALSO, target_evernote_guid, link_number, enLink.Uid, enLink.Shard, toc_evernote_guid, + toc_link_html.replace(u'\'', u'\'\''), toc_link_title.replace(u'\'', u'\'\''), TABLES.SEE_ALSO, + TABLES.SEE_ALSO, target_evernote_guid, toc_evernote_guid) + log_sql('UPDATE_ANKI_DB: Add See Also Link: SQL Query: ' + query) + ankDB().execute(query) + query_update_toc_links += delimiter + "target_evernote_guid = '%s'" % toc_evernote_guid + delimiter = " OR " + ankDB().execute(query_update_toc_links) + ankDB().commit() + + def insert_toc_and_outline_contents_into_notes(self): + linked_notes_fields = {} + source_guids = ankDB().list( + "select DISTINCT source_evernote_guid from %s WHERE is_toc = 1 ORDER BY source_evernote_guid ASC" % TABLES.SEE_ALSO) + source_guids_count = len(source_guids) + i = 0 + for source_evernote_guid in source_guids: + i += 1 + note = self.get_anki_note_from_evernote_guid(source_evernote_guid) + if not note: continue + if TAGS.TOC in note.tags: continue + for fld in note._model['flds']: + if FIELDS.TITLE in fld.get('name'): + note_title = note.fields[fld.get('ord')] + continue + if not note_title: + log_error("Could not find note title for %s for insert_toc_and_outline_contents_into_notes" % note.guid) + continue + note_toc = "" + note_outline = "" + toc_header = "" + outline_header = "" + toc_count = 0 + outline_count = 0 + toc_and_outline_links = ankDB().execute( + "select target_evernote_guid, is_toc, is_outline from %s WHERE source_evernote_guid = '%s' AND (is_toc = 1 OR is_outline = 1) ORDER BY number ASC" % ( + TABLES.SEE_ALSO, source_evernote_guid)) + for target_evernote_guid, is_toc, is_outline in toc_and_outline_links: + if target_evernote_guid in linked_notes_fields: + linked_note_contents = linked_notes_fields[target_evernote_guid][FIELDS.CONTENT] + linked_note_title = linked_notes_fields[target_evernote_guid][FIELDS.TITLE] + else: + linked_note = self.get_anki_note_from_evernote_guid(target_evernote_guid) + if not linked_note: continue + linked_note_contents = u"" + for fld in linked_note._model['flds']: + if FIELDS.CONTENT in fld.get('name'): + linked_note_contents = linked_note.fields[fld.get('ord')] + elif FIELDS.TITLE in fld.get('name'): + linked_note_title = linked_note.fields[fld.get('ord')] + if linked_note_contents: + linked_notes_fields[target_evernote_guid] = { + FIELDS.TITLE: linked_note_title, + FIELDS.CONTENT: linked_note_contents + } + if linked_note_contents: + if isinstance(linked_note_contents, str): + linked_note_contents = unicode(linked_note_contents, 'utf-8') + if (is_toc or is_outline) and (toc_count + outline_count is 0): + log(" > [%3d/%3d] Found TOC/Outline for Note '%s': %s" % (i, source_guids_count, source_evernote_guid, note_title), 'See Also') + if is_toc: + toc_count += 1 + if toc_count is 1: + toc_header = "<span class='header'>TABLE OF CONTENTS</span>: 1. <span class='header'>%s</span>" % linked_note_title + else: + toc_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % ( + toc_count, linked_note_title) + note_toc += "<br><hr>" + + note_toc += linked_note_contents + log(" > Appending TOC #%d contents" % toc_count, 'See Also') + else: + outline_count += 1 + if outline_count is 1: + outline_header = "<span class='header'>OUTLINE</span>: 1. <span class='header'>%s</span>" % linked_note_title + else: + outline_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % ( + outline_count, linked_note_title) + note_outline += "<BR><HR>" + + note_outline += linked_note_contents + log(" > Appending Outline #%d contents" % outline_count, 'See Also') + + if outline_count + toc_count > 0: + if outline_count > 1: + note_outline = "<span class='Outline'>%s</span><BR><BR>" % outline_header + note_outline + if toc_count > 1: + note_toc = "<span class='TOC'>%s</span><BR><BR>" % toc_header + note_toc + for fld in note._model['flds']: + if FIELDS.TOC in fld.get('name'): + note.fields[fld.get('ord')] = note_toc + elif FIELDS.OUTLINE in fld.get('name'): + note.fields[fld.get('ord')] = note_outline + log(" > Flushing Note \r\n", 'See Also') + note.flush() + + def start_editing(self): + self.window().requireReset() + + def stop_editing(self): + if self.collection(): + self.window().maybeReset() + + @staticmethod + def window(): + """ + :rtype : AnkiQt + :return: + """ + return aqt.mw + + def collection(self): + return self.window().col + + def models(self): + return self.collection().models + + def decks(self): + return self.collection().decks diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 96c977b..d8d1e5d 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -7,473 +7,486 @@ try: - import anki - from anki.notes import Note as AnkiNote - from aqt import mw + import anki + from anki.notes import Note as AnkiNote + from aqt import mw except: - pass + pass def get_self_referential_fmap(): - fmap = {} - for i in range(0, len(FIELDS.LIST)): - fmap[i] = i - return fmap + fmap = {} + for i in range(0, len(FIELDS.LIST)): + fmap[i] = i + return fmap class AnkiNotePrototype: - Anki = None - """:type : anknotes.Anki.Anki """ - BaseNote = None - """:type : AnkiNote """ - enNote = None - """:type: EvernoteNotePrototype.EvernoteNotePrototype""" - Fields = {} - """:type : dict[str, str]""" - Tags = [] - """:type : list[str]""" - ModelName = None - """:type : str""" - Guid = "" - """:type : str""" - NotebookGuid = "" - """:type : str""" - __cloze_count__ = 0 - - class Counts: - Updated = 0 - Current = 0 - Max = 1 - - OriginalGuid = None - """:type : str""" - Changed = False - _unprocessed_content_ = "" - _unprocessed_see_also_ = "" - _log_update_if_unchanged_ = True - - def __init__(self, anki=None, fields=None, tags=None, base_note=None, notebookGuid=None, count=-1, count_update=0, - max_count=1, counts=None, light_processing=False, enNote=None): - """ - Create Anki Note Prototype Class from fields or Base Anki Note - :param anki: Anki: Anknotes Main Class Instance - :type anki: anknotes.Anki.Anki - :param fields: Dict of Fields - :param tags: List of Tags - :type tags : list[str] - :param base_note: Base Anki Note if Updating an Existing Note - :type base_note : anki.notes.Note - :param enNote: Base Evernote Note Prototype from Anknotes DB, usually used just to process a note's contents - :type enNote : EvernoteNotePrototype.EvernoteNotePrototype - :param notebookGuid: - :param count: - :param count_update: - :param max_count: - :param counts: AnkiNotePrototype.Counts if being used to add/update multiple notes - :type counts : AnkiNotePrototype.Counts - :return: AnkiNotePrototype - """ - self.light_processing = light_processing - self.Anki = anki - self.Fields = fields - self.BaseNote = base_note - if enNote and light_processing and not fields: - self.Fields = {FIELDS.TITLE: enNote.Title.FullTitle, FIELDS.CONTENT: enNote.Content, FIELDS.SEE_ALSO: u'', FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + enNote.Guid} - self.enNote = enNote - self.Changed = False - self.logged = False - if counts: - self.Counts = counts - else: - self.Counts.Updated = count_update - self.Counts.Current = count + 1 - self.Counts.Max = max_count - self.initialize_fields() - self.Guid = get_evernote_guid_from_anki_fields(self.Fields) - self.NotebookGuid = notebookGuid - self.ModelName = None # MODELS.EVERNOTE_DEFAULT - # self.Title = EvernoteNoteTitle() - if not self.NotebookGuid and self.Anki: - self.NotebookGuid = self.Anki.get_notebook_guid_from_ankdb(self.Guid) - assert self.Guid and (self.light_processing or self.NotebookGuid) - self._deck_parent_ = self.Anki.deck if self.Anki else '' - self.Tags = tags - self.__cloze_count__ = 0 - self.process_note() - - def initialize_fields(self): - if self.BaseNote: - self.originalFields = get_dict_from_list(self.BaseNote.items()) - for field in FIELDS.LIST: - if not field in self.Fields: - self.Fields[field] = self.originalFields[field] if self.BaseNote else u'' - # self.Title = EvernoteNoteTitle(self.Fields) - - def deck(self): - deck = self._deck_parent_ - if EVERNOTE.TAG.TOC in self.Tags or EVERNOTE.TAG.AUTO_TOC in self.Tags: - deck += DECKS.TOC_SUFFIX - elif EVERNOTE.TAG.OUTLINE in self.Tags and EVERNOTE.TAG.OUTLINE_TESTABLE not in self.Tags: - deck += DECKS.OUTLINE_SUFFIX - elif not self._deck_parent_ or mw.col.conf.get(SETTINGS.ANKI_DECK_EVERNOTE_NOTEBOOK_INTEGRATION, True): - deck = self.Anki.get_deck_name_from_evernote_notebook(self.NotebookGuid, self._deck_parent_) - if not deck: return None - if deck[:2] == '::': - deck = deck[2:] - return deck - - def evernote_cloze_regex(self, match): - matchText = match.group(2) - if matchText[0] == "#": - matchText = matchText[1:] - else: - self.__cloze_count__ += 1 - if self.__cloze_count__ == 0: - self.__cloze_count__ = 1 - return "%s{{c%d::%s}}%s" % (match.group(1), self.__cloze_count__, matchText, match.group(3)) - - def process_note_see_also(self): - if not FIELDS.SEE_ALSO in self.Fields or not FIELDS.EVERNOTE_GUID in self.Fields: - return - ankDB().execute("DELETE FROM %s WHERE source_evernote_guid = '%s' " % (TABLES.SEE_ALSO, self.Guid)) - link_num = 0 - for enLink in find_evernote_links(self.Fields[FIELDS.SEE_ALSO]): - link_num += 1 - title_text = enLink.FullTitle - is_toc = 1 if (title_text == "TOC") else 0 - is_outline = 1 if (title_text is "O" or title_text is "Outline") else 0 - ankDB().execute( - "INSERT INTO %s (source_evernote_guid, number, uid, shard, target_evernote_guid, html, title, from_toc, is_toc, is_outline) VALUES('%s', %d, %d, '%s', '%s', '%s', '%s', 0, %d, %d)" % ( - TABLES.SEE_ALSO, self.Guid, link_num, enLink.Uid, enLink.Shard, - enLink.Guid, enLink.HTML, title_text, is_toc, is_outline)) - - def process_note_content(self): - - def step_0_remove_evernote_css_attributes(): - ################################### Step 0: Correct weird Evernote formatting - remove_style_attrs = '-webkit-text-size-adjust: auto|-webkit-text-stroke-width: 0px|background-color: rgb(255, 255, 255)|color: rgb(0, 0, 0)|font-family: Tahoma|font-size: medium;|font-style: normal|font-variant: normal|font-weight: normal|letter-spacing: normal|orphans: 2|text-align: -webkit-auto|text-indent: 0px|text-transform: none|white-space: normal|widows: 2|word-spacing: 0px|word-wrap: break-word|-webkit-nbsp-mode: space|-webkit-line-break: after-white-space'.replace( - '(', '\\(').replace(')', '\\)') - # 'margin: 0px; padding: 0px 0px 0px 40px; ' - self.Fields[FIELDS.CONTENT] = re.sub(r' ?(%s);? ?' % remove_style_attrs, '', self.Fields[FIELDS.CONTENT]) - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(' style=""', '') - - def step_1_modify_evernote_links(): - ################################### Step 1: Modify Evernote Links - # We need to modify Evernote's "Classic" Style Note Links due to an Anki bug with executing the evernote command with three forward slashes. - # For whatever reason, Anki cannot handle evernote links with three forward slashes, but *can* handle links with two forward slashes. - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote:///", "evernote://") - - # Modify Evernote's "New" Style Note links that point to the Evernote website. Normally these links open the note using Evernote's web client. - # The web client then opens the local Evernote executable. Modifying the links as below will skip this step and open the note directly using the local Evernote executable - self.Fields[FIELDS.CONTENT] = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', - r'evernote://view/\2/\1/\3/\3/', self.Fields[FIELDS.CONTENT]) - - if self.light_processing: - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote://", "evernote:///") - - def step_2_modify_image_links(): - ################################### Step 2: Modify Image Links - # Currently anknotes does not support rendering images embedded into an Evernote note. - # As a work around, this code will convert any link to an image on Dropbox, to an embedded <img> tag. - # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page - # Step 2.1: Modify HTML links to Dropbox images - dropbox_image_url_base_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))' - dropbox_image_url_html_link_regex = dropbox_image_url_base_regex + r'(?P<QueryString>(?:\?dl=(?:0|1))?)' - dropbox_image_src_subst = r'<a href="\g<URL>\g<QueryString>"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' - self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="%s"[^>]*>(?P<Title>.+?)</a>' % dropbox_image_url_html_link_regex, - dropbox_image_src_subst % "'\g<Title>'", self.Fields[FIELDS.CONTENT]) - - # Step 2.2: Modify Plain-text links to Dropbox images - try: - dropbox_image_url_regex = dropbox_image_url_base_regex + r'(?P<QueryString>\?dl=(?:0|1))(?P<Suffix>"?[^">])' - self.Fields[FIELDS.CONTENT] = re.sub(dropbox_image_url_regex, (dropbox_image_src_subst % "From Plain-Text Link") + r'\g<Suffix>', self.Fields[FIELDS.CONTENT]) - except: - log_error("\nERROR processing note, Step 2.2. Content: %s" % self.Fields[FIELDS.CONTENT]) - - # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link)" - self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="(?P<URL>.+?)"[^>]*>(?P<Title>\(Image Link.*\))</a>', - r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', - self.Fields[FIELDS.CONTENT]) - - def step_3_occlude_text(): - ################################### Step 3: Change white text to transparent - # I currently use white text in Evernote to display information that I want to be initially hidden, but visible when desired by selecting the white text. - # We will change the white text to a special "occluded" CSS class so it can be visible on the back of cards, and also so we can adjust the color for the front of cards when using night mode - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') - - ################################### Step 4: Automatically Occlude Text in <<Double Angle Brackets>> - self.Fields[FIELDS.CONTENT] = re.sub("(?s)(?P<Prefix><|<) ?(?P=Prefix) ?(?P<PrefixKeep>(?:</div>)?)(?P<OccludedText>.+?)(?P<Suffix>>|>) ?(?P=Suffix) ?", r'<<\g<PrefixKeep><div class="occluded">\g<OccludedText></div>>>', self.Fields[FIELDS.CONTENT]) - - def step_5_create_cloze_fields(): - ################################### Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. - self.Fields[FIELDS.CONTENT] = re.sub(r'([^{]){([^{].*?)}([^}])', self.evernote_cloze_regex, self.Fields[FIELDS.CONTENT]) - - def step_6_process_see_also_links(): - ################################### Step 6: Process "See Also: " Links - see_also_match = regex_see_also().search(self.Fields[FIELDS.CONTENT]) - if not see_also_match: - if self.Fields[FIELDS.CONTENT].find("See Also") > -1: - log("No See Also Content Found, but phrase 'See Also' exists in " + self.Title.FullTitle + " \n" + self.Fields[FIELDS.CONTENT]) - raise ValueError - return - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group(0), see_also_match.group('Suffix')) - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<div><b><br/></b></div></en-note>', '</en-note>') - see_also = see_also_match.group('SeeAlso') - see_also_header = see_also_match.group('SeeAlsoHeader') - see_also_header_stripme = see_also_match.group('SeeAlsoHeaderStripMe') - if see_also_header_stripme: - see_also = see_also.replace(see_also_header, see_also_header.replace(see_also_header_stripme, '')) - if self.Fields[FIELDS.SEE_ALSO]: - self.Fields[FIELDS.SEE_ALSO] += "<br><br>\r\n" - self.Fields[FIELDS.SEE_ALSO] += see_also - if self.light_processing: - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group('Suffix'), self.Fields[FIELDS.SEE_ALSO] + see_also_match.group('Suffix')) - return - self.process_note_see_also() - if not FIELDS.CONTENT in self.Fields: - return - self._unprocessed_content_ = self.Fields[FIELDS.CONTENT] - self._unprocessed_see_also_ = self.Fields[FIELDS.SEE_ALSO] - steps = [0, 1, 6] if self.light_processing else range(0,7) - if self.light_processing and not ANKNOTES.NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING: - steps.remove(0) - if 0 in steps: step_0_remove_evernote_css_attributes() - step_1_modify_evernote_links() - if 2 in steps: - step_2_modify_image_links() - step_3_occlude_text() - step_5_create_cloze_fields() - step_6_process_see_also_links() - # TODO: Add support for extracting an 'Extra' field from the Evernote Note contents - ################################### Note Processing complete. - - def detect_note_model(self): - log('\nTitle, self.model_name, tags, self.model_name', 'detectnotemodel') - log(self.Fields[FIELDS.TITLE], 'detectnotemodel') - log(self.ModelName, 'detectnotemodel') - if FIELDS.CONTENT in self.Fields and "{{c1::" in self.Fields[FIELDS.CONTENT]: - self.ModelName = MODELS.EVERNOTE_CLOZE - if len(self.Tags) > 0: - reverse_override = (EVERNOTE.TAG.TOC in self.Tags or EVERNOTE.TAG.AUTO_TOC in self.Tags) - if EVERNOTE.TAG.REVERSIBLE in self.Tags: - self.ModelName = MODELS.EVERNOTE_REVERSIBLE - self.Tags.remove(EVERNOTE.TAG.REVERSIBLE) - elif EVERNOTE.TAG.REVERSE_ONLY in self.Tags: - self.ModelName = MODELS.EVERNOTE_REVERSE_ONLY - self.Tags.remove(EVERNOTE.TAG.REVERSE_ONLY) - if reverse_override: - self.ModelName = MODELS.EVERNOTE_DEFAULT - - log(self.Tags, 'detectnotemodel') - log(self.ModelName, 'detectnotemodel') - - def model_id(self): - if not self.ModelName: return None - return long(self.Anki.models().byName(self.ModelName)['id']) - - def process_note(self): - self.process_note_content() - if not self.light_processing: - self.detect_note_model() - - def update_note_model(self): - modelNameNew = self.ModelName - if not modelNameNew: return False - modelIdOld = self.note.mid - modelIdNew = self.model_id() - if modelIdOld == modelIdNew: - return False - mm = self.Anki.models() - modelOld = self.note.model() - modelNew = mm.get(modelIdNew) - modelNameOld = modelOld['name'] - fmap = get_self_referential_fmap() - cmap = {0: 0} - if modelNameOld == MODELS.EVERNOTE_REVERSE_ONLY and modelNameNew == MODELS.EVERNOTE_REVERSIBLE: - cmap[0] = 1 - elif modelNameOld == MODELS.EVERNOTE_REVERSIBLE: - if modelNameNew == MODELS.EVERNOTE_REVERSE_ONLY: - cmap = {0: None, 1: 0} - else: - cmap[1] = None - self.log_update("Changing model:\n From: '%s' \n To: '%s'" % (modelNameOld, modelNameNew)) - mm.change(modelOld, [self.note.id], modelNew, fmap, cmap) - self.Changed = True - return True - - def log_update(self, content=''): - if not self.logged: - count_updated_new = (self.Counts.Updated + 1 if content else 0) - count_str = '' - if self.Counts.Current > 0: - count_str = ' [' - if self.Counts.Current - count_updated_new > 0 and count_updated_new > 0: - count_str += '%3d/' % count_updated_new - count_str += '%-4d]/[' % self.Counts.Current - else: - count_str += '%4d/' % self.Counts.Current - count_str += '%-4d]' % self.Counts.Max - count_str += ' (%2d%%)' % (float(self.Counts.Current) / self.Counts.Max * 100) - log_title = '!' if content else '' - log_title += 'UPDATING NOTE%s: %-80s: %s' % (count_str, self.Fields[FIELDS.TITLE], - self.Fields[FIELDS.EVERNOTE_GUID].replace( - FIELDS.EVERNOTE_GUID_PREFIX, '')) - log(log_title, 'AddUpdateNote', timestamp=(content is ''), - clear=((self.Counts.Current == 1 or self.Counts.Current == 100) and not self.logged)) - self.logged = True - if not content: return - content = obj2log_simple(content) - content = content.replace('\n', '\n ') - log(' > %s\n' % content, 'AddUpdateNote', timestamp=False) - - def update_note_tags(self): - if len(self.Tags) == 0: return False - self.Tags = get_tag_names_to_import(self.Tags) - if not self.BaseNote: - self.log_update("Error with unt") - self.log_update(self.Tags) - self.log_update(self.Fields) - self.log_update(self.BaseNote) - assert self.BaseNote - baseTags = sorted(self.BaseNote.tags, key=lambda s: s.lower()) - value = u','.join(self.Tags) - value_original = u','.join(baseTags) - if str(value) == str(value_original): - return False - self.log_update("Changing tags:\n From: '%s' \n To: '%s'" % (value_original, value)) - self.BaseNote.tags = self.Tags - self.Changed = True - return True - - def update_note_deck(self): - deckNameNew = self.deck() - if not deckNameNew: return False - deckIDNew = self.Anki.decks().id(deckNameNew) - deckIDOld = get_anki_deck_id_from_note_id(self.note.id) - if deckIDNew == deckIDOld: - return False - self.log_update( - "Changing deck:\n From: '%s' \n To: '%s'" % (self.Anki.decks().nameOrNone(deckIDOld), self.deck())) - # Not sure if this is necessary or Anki does it by itself: - ankDB().execute("UPDATE cards SET did = ? WHERE nid = ?", deckIDNew, self.note.id) - return True - - def update_note_fields(self): - fields_to_update = [FIELDS.TITLE, FIELDS.CONTENT, FIELDS.SEE_ALSO, FIELDS.UPDATE_SEQUENCE_NUM] - fld_content_ord = -1 - flag_changed = False - field_updates = [] - fields_updated = {} - for fld in self.note._model['flds']: - if FIELDS.EVERNOTE_GUID in fld.get('name'): - self.OriginalGuid = self.note.fields[fld.get('ord')].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') - for field_to_update in fields_to_update: - if field_to_update == fld.get('name') and field_to_update in self.Fields: - if field_to_update is FIELDS.CONTENT: - fld_content_ord = fld.get('ord') - try: - value = self.Fields[field_to_update] - value_original = self.note.fields[fld.get('ord')] - if isinstance(value, str): - value = unicode(value, 'utf-8') - if isinstance(value_original, str): - value_original = unicode(value_original, 'utf-8') - if not value == value_original: - flag_changed = True - self.note.fields[fld.get('ord')] = value - fields_updated[field_to_update] = value_original - if field_to_update is FIELDS.CONTENT or field_to_update is FIELDS.SEE_ALSO: - diff = generate_diff(value_original, value) - else: - diff = 'From: \n%s \n\n To: \n%s' % (value_original, value) - field_updates.append("Changing field #%d %s:\n%s" % (fld.get('ord'), field_to_update, diff)) - except: - self.log_update(field_updates) - log_error( - "ERROR: UPDATE_NOTE: Note '%s': %s: Unable to set self.note.fields for field '%s'. Ord: %s. Note fields count: %d" % ( - self.Guid, self.Fields[FIELDS.TITLE], field_to_update, str(fld.get('ord')), - len(self.note.fields))) - raise - for update in field_updates: - self.log_update(update) - if flag_changed: self.Changed = True - return flag_changed - - def update_note(self): - self.note = self.BaseNote - self.logged = False - if not self.BaseNote: - self.log_update("Not updating Note: Could not find base note") - return False - self.Changed = False - self.update_note_tags() - self.update_note_fields() - if 'See Also' in self.Fields[FIELDS.CONTENT]: - raise ValueError - if not (self.Changed or self.update_note_deck()): - if self._log_update_if_unchanged_: - self.log_update("Not updating Note: The fields, tags, and deck are the same") - elif ( - self.Counts.Updated is 0 or self.Counts.Current / self.Counts.Updated > 9) and self.Counts.Current % 100 is 0: - self.log_update() - return False - # i.e., the note deck has been changed but the tags and fields have not - if not self.Changed: - self.Counts.Updated += 1 - return True - if not self.OriginalGuid: - flds = get_dict_from_list(self.BaseNote.items()) - self.OriginalGuid = get_evernote_guid_from_anki_fields(flds) - db_title = ankDB().scalar( - "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.OriginalGuid)) - if self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') != self.OriginalGuid or \ - self.Fields[FIELDS.TITLE] != db_title: - self.log_update(' %s: DB: ' % self.OriginalGuid + ' ' + db_title) - self.note.flush() - self.update_note_model() - # self. - self.Counts.Updated += 1 - return True - - @property - def Title(self): - """:rtype : EvernoteNoteTitle.EvernoteNoteTitle """ - title = "" - if FIELDS.TITLE in self.Fields: - title = self.Fields[FIELDS.TITLE] - if self.BaseNote: - title = self.originalFields[FIELDS.TITLE] - return EvernoteNoteTitle(title) - - def add_note(self): - self.create_note() - if self.note is not None: - collection = self.Anki.collection() - db_title = ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % ( - TABLES.EVERNOTE.NOTES, self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, ''))) - log(' %s: ADD: ' % self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') + ' ' + - self.Fields[FIELDS.TITLE], 'AddUpdateNote') - if self.Fields[FIELDS.TITLE] != db_title: - log(' %s: DB TITLE: ' % re.sub(r'.', ' ', self.Fields[FIELDS.EVERNOTE_GUID].replace( - FIELDS.EVERNOTE_GUID_PREFIX, '')) + ' ' + db_title, 'AddUpdateNote') - try: - collection.addNote(self.note) - except: - log_error("Unable to collection.addNote for Note %s: %s" % ( - self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, ''), db_title)) - log_dump(self.note.fields, '- FAILED collection.addNote: ') - return -1 - collection.autosave() - self.Anki.start_editing() - return self.note.id - - def create_note(self): - id_deck = self.Anki.decks().id(self.deck()) - if not self.ModelName: self.ModelName = MODELS.EVERNOTE_DEFAULT - model = self.Anki.models().byName(self.ModelName) - col = self.Anki.collection() - self.note = AnkiNote(col, model) - self.note.model()['did'] = id_deck - self.note.tags = self.Tags - for name, value in self.Fields.items(): - self.note[name] = value + Anki = None + """:type : anknotes.Anki.Anki """ + BaseNote = None + """:type : AnkiNote """ + enNote = None + """:type: EvernoteNotePrototype.EvernoteNotePrototype""" + Fields = {} + """:type : dict[str, str]""" + Tags = [] + """:type : list[str]""" + ModelName = None + """:type : str""" + Guid = "" + """:type : str""" + NotebookGuid = "" + """:type : str""" + __cloze_count__ = 0 + + class Counts: + Updated = 0 + Current = 0 + Max = 1 + + OriginalGuid = None + """:type : str""" + Changed = False + _unprocessed_content_ = "" + _unprocessed_see_also_ = "" + _log_update_if_unchanged_ = True + + def __init__(self, anki=None, fields=None, tags=None, base_note=None, notebookGuid=None, count=-1, count_update=0, + max_count=1, counts=None, light_processing=False, enNote=None): + """ + Create Anki Note Prototype Class from fields or Base Anki Note + :param anki: Anki: Anknotes Main Class Instance + :type anki: anknotes.Anki.Anki + :param fields: Dict of Fields + :param tags: List of Tags + :type tags : list[str] + :param base_note: Base Anki Note if Updating an Existing Note + :type base_note : anki.notes.Note + :param enNote: Base Evernote Note Prototype from Anknotes DB, usually used just to process a note's contents + :type enNote : EvernoteNotePrototype.EvernoteNotePrototype + :param notebookGuid: + :param count: + :param count_update: + :param max_count: + :param counts: AnkiNotePrototype.Counts if being used to add/update multiple notes + :type counts : AnkiNotePrototype.Counts + :return: AnkiNotePrototype + """ + self.light_processing = light_processing + self.Anki = anki + self.Fields = fields + self.BaseNote = base_note + if enNote and light_processing and not fields: + self.Fields = {FIELDS.TITLE: enNote.Title.FullTitle, FIELDS.CONTENT: enNote.Content, FIELDS.SEE_ALSO: u'', FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + enNote.Guid} + self.enNote = enNote + self.Changed = False + self.logged = False + if counts: + self.Counts = counts + else: + self.Counts.Updated = count_update + self.Counts.Current = count + 1 + self.Counts.Max = max_count + self.initialize_fields() + self.Guid = get_evernote_guid_from_anki_fields(self.Fields) + self.NotebookGuid = notebookGuid + self.ModelName = None # MODELS.DEFAULT + # self.Title = EvernoteNoteTitle() + if not self.NotebookGuid and self.Anki: + self.NotebookGuid = self.Anki.get_notebook_guid_from_ankdb(self.Guid) + if not self.Guid and (self.light_processing or self.NotebookGuid): + log('Guid/Notebook Guid missing for: ' + self.Fields[FIELDS.TITLE]) + log(self.Guid) + log(self.NotebookGuid) + raise ValueError + self._deck_parent_ = self.Anki.deck if self.Anki else '' + assert tags is not None + self.Tags = tags + self.__cloze_count__ = 0 + self.process_note() + + def initialize_fields(self): + if self.BaseNote: + self.originalFields = get_dict_from_list(self.BaseNote.items()) + for field in FIELDS.LIST: + if not field in self.Fields: + self.Fields[field] = self.originalFields[field] if self.BaseNote else u'' + # self.Title = EvernoteNoteTitle(self.Fields) + + def deck(self): + deck = self._deck_parent_ + if TAGS.TOC in self.Tags or TAGS.AUTO_TOC in self.Tags: + deck += DECKS.TOC_SUFFIX + elif TAGS.OUTLINE in self.Tags and TAGS.OUTLINE_TESTABLE not in self.Tags: + deck += DECKS.OUTLINE_SUFFIX + elif not self._deck_parent_ or mw.col.conf.get(SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION, True): + deck = self.Anki.get_deck_name_from_evernote_notebook(self.NotebookGuid, self._deck_parent_) + if not deck: return None + if deck[:2] == '::': + deck = deck[2:] + return deck + + def evernote_cloze_regex(self, match): + matchText = match.group(2) + if matchText[0] == "#": + matchText = matchText[1:] + else: + self.__cloze_count__ += 1 + if self.__cloze_count__ == 0: + self.__cloze_count__ = 1 + return "%s{{c%d::%s}}%s" % (match.group(1), self.__cloze_count__, matchText, match.group(3)) + + def process_note_see_also(self): + if not FIELDS.SEE_ALSO in self.Fields or not FIELDS.EVERNOTE_GUID in self.Fields: + return + ankDB().execute("DELETE FROM %s WHERE source_evernote_guid = '%s' " % (TABLES.SEE_ALSO, self.Guid)) + link_num = 0 + for enLink in find_evernote_links(self.Fields[FIELDS.SEE_ALSO]): + link_num += 1 + title_text = enLink.FullTitle + is_toc = 1 if (title_text == "TOC") else 0 + is_outline = 1 if (title_text is "O" or title_text is "Outline") else 0 + ankDB().execute( + "INSERT INTO %s (source_evernote_guid, number, uid, shard, target_evernote_guid, html, title, from_toc, is_toc, is_outline) VALUES('%s', %d, %d, '%s', '%s', '%s', '%s', 0, %d, %d)" % ( + TABLES.SEE_ALSO, self.Guid, link_num, enLink.Uid, enLink.Shard, + enLink.Guid, enLink.HTML, title_text, is_toc, is_outline)) + + def process_note_content(self): + + def step_0_remove_evernote_css_attributes(): + ################################### Step 0: Correct weird Evernote formatting + self.Fields[FIELDS.CONTENT] = clean_evernote_css(self.Fields[FIELDS.CONTENT]) + + def step_1_modify_evernote_links(): + ################################### Step 1: Modify Evernote Links + # We need to modify Evernote's "Classic" Style Note Links due to an Anki bug with executing the evernote command with three forward slashes. + # For whatever reason, Anki cannot handle evernote links with three forward slashes, but *can* handle links with two forward slashes. + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote:///", "evernote://") + + # Modify Evernote's "New" Style Note links that point to the Evernote website. Normally these links open the note using Evernote's web client. + # The web client then opens the local Evernote executable. Modifying the links as below will skip this step and open the note directly using the local Evernote executable + self.Fields[FIELDS.CONTENT] = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', + r'evernote://view/\2/\1/\3/\3/', self.Fields[FIELDS.CONTENT]) + + if self.light_processing: + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote://", "evernote:///") + + def step_2_modify_image_links(): + ################################### Step 2: Modify Image Links + # Currently anknotes does not support rendering images embedded into an Evernote note. + # As a work around, this code will convert any link to an image on Dropbox, to an embedded <img> tag. + # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page + # Step 2.1: Modify HTML links to Dropbox images + dropbox_image_url_base_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))' + dropbox_image_url_html_link_regex = dropbox_image_url_base_regex + r'(?P<QueryString>(?:\?dl=(?:0|1))?)' + dropbox_image_src_subst = r'<a href="\g<URL>\g<QueryString>"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' + self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="%s"[^>]*>(?P<Title>.+?)</a>' % dropbox_image_url_html_link_regex, + dropbox_image_src_subst % "'\g<Title>'", self.Fields[FIELDS.CONTENT]) + + # Step 2.2: Modify Plain-text links to Dropbox images + try: + dropbox_image_url_regex = dropbox_image_url_base_regex + r'(?P<QueryString>\?dl=(?:0|1))(?P<Suffix>"?[^">])' + self.Fields[FIELDS.CONTENT] = re.sub(dropbox_image_url_regex, (dropbox_image_src_subst % "From Plain-Text Link") + r'\g<Suffix>', self.Fields[FIELDS.CONTENT]) + except: + log_error("\nERROR processing note, Step 2.2. Content: %s" % self.Fields[FIELDS.CONTENT]) + + # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link)" + self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="(?P<URL>.+?)"[^>]*>(?P<Title>\(Image Link.*\))</a>', + r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', + self.Fields[FIELDS.CONTENT]) + + def step_3_occlude_text(): + ################################### Step 3: Change white text to transparent + # I currently use white text in Evernote to display information that I want to be initially hidden, but visible when desired by selecting the white text. + # We will change the white text to a special "occluded" CSS class so it can be visible on the back of cards, and also so we can adjust the color for the front of cards when using night mode + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') + + ################################### Step 4: Automatically Occlude Text in <<Double Angle Brackets>> + self.Fields[FIELDS.CONTENT] = re.sub("(?s)(?P<Prefix><|<) ?(?P=Prefix) ?(?P<PrefixKeep>(?:</div>)?)(?P<OccludedText>.+?)(?P<Suffix>>|>) ?(?P=Suffix) ?", r'<<\g<PrefixKeep><div class="occluded">\g<OccludedText></div>>>', self.Fields[FIELDS.CONTENT]) + + def step_5_create_cloze_fields(): + ################################### Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. + self.Fields[FIELDS.CONTENT] = re.sub(r'([^{]){([^{].*?)}([^}])', self.evernote_cloze_regex, self.Fields[FIELDS.CONTENT]) + + def step_6_process_see_also_links(): + ################################### Step 6: Process "See Also: " Links + see_also_match = regex_see_also().search(self.Fields[FIELDS.CONTENT]) + if not see_also_match: + if self.Fields[FIELDS.CONTENT].find("See Also") > -1: + log("No See Also Content Found, but phrase 'See Also' exists in " + self.Title.FullTitle + " \n" + self.Fields[FIELDS.CONTENT]) + raise ValueError + return + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group(0), see_also_match.group('Suffix')) + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<div><b><br/></b></div></en-note>', '</en-note>') + see_also = see_also_match.group('SeeAlso') + see_also_header = see_also_match.group('SeeAlsoHeader') + see_also_header_stripme = see_also_match.group('SeeAlsoHeaderStripMe') + if see_also_header_stripme: + see_also = see_also.replace(see_also_header, see_also_header.replace(see_also_header_stripme, '')) + if self.Fields[FIELDS.SEE_ALSO]: + self.Fields[FIELDS.SEE_ALSO] += "<br><br>\r\n" + self.Fields[FIELDS.SEE_ALSO] += see_also + if self.light_processing: + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group('Suffix'), self.Fields[FIELDS.SEE_ALSO] + see_also_match.group('Suffix')) + return + self.process_note_see_also() + + if not FIELDS.CONTENT in self.Fields: + return + self._unprocessed_content_ = self.Fields[FIELDS.CONTENT] + self._unprocessed_see_also_ = self.Fields[FIELDS.SEE_ALSO] + steps = [0, 1, 6] if self.light_processing else range(0,7) + if self.light_processing and not ANKI.NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING: + steps.remove(0) + if 0 in steps: step_0_remove_evernote_css_attributes() + step_1_modify_evernote_links() + if 2 in steps: + step_2_modify_image_links() + step_3_occlude_text() + step_5_create_cloze_fields() + step_6_process_see_also_links() + # TODO: Add support for extracting an 'Extra' field from the Evernote Note contents + ################################### Note Processing complete. + + def detect_note_model(self): + + # log('Title, self.model_name, tags, self.model_name', 'detectnotemodel') + # log(self.Fields[FIELDS.TITLE], 'detectnotemodel') + # log(self.ModelName, 'detectnotemodel') + if FIELDS.CONTENT in self.Fields and "{{c1::" in self.Fields[FIELDS.CONTENT]: + self.ModelName = MODELS.CLOZE + if len(self.Tags) > 0: + reverse_override = (TAGS.TOC in self.Tags or TAGS.AUTO_TOC in self.Tags) + if TAGS.REVERSIBLE in self.Tags: + self.ModelName = MODELS.REVERSIBLE + self.Tags.remove(TAGS.REVERSIBLE) + elif TAGS.REVERSE_ONLY in self.Tags: + self.ModelName = MODELS.REVERSE_ONLY + self.Tags.remove(TAGS.REVERSE_ONLY) + if reverse_override: + self.ModelName = MODELS.DEFAULT + + # log(self.Tags, 'detectnotemodel') + # log(self.ModelName, 'detectnotemodel') + + def model_id(self): + if not self.ModelName: return None + return long(self.Anki.models().byName(self.ModelName)['id']) + + def process_note(self): + self.process_note_content() + if not self.light_processing: + self.detect_note_model() + + def update_note_model(self): + modelNameNew = self.ModelName + if not modelNameNew: return False + modelIdOld = self.note.mid + modelIdNew = self.model_id() + if modelIdOld == modelIdNew: + return False + mm = self.Anki.models() + modelOld = self.note.model() + modelNew = mm.get(modelIdNew) + modelNameOld = modelOld['name'] + fmap = get_self_referential_fmap() + cmap = {0: 0} + if modelNameOld == MODELS.REVERSE_ONLY and modelNameNew == MODELS.REVERSIBLE: + cmap[0] = 1 + elif modelNameOld == MODELS.REVERSIBLE: + if modelNameNew == MODELS.REVERSE_ONLY: + cmap = {0: None, 1: 0} + else: + cmap[1] = None + self.log_update("Changing model:\n From: '%s' \n To: '%s'" % (modelNameOld, modelNameNew)) + mm.change(modelOld, [self.note.id], modelNew, fmap, cmap) + self.Changed = True + return True + + def log_update(self, content=''): + if not self.logged: + count_updated_new = (self.Counts.Updated + 1 if content else 0) + count_str = '' + if self.Counts.Current > 0: + count_str = ' [' + if self.Counts.Current - count_updated_new > 0 and count_updated_new > 0: + count_str += '%3d/' % count_updated_new + count_str += '%-4d]/[' % self.Counts.Current + else: + count_str += '%4d/' % self.Counts.Current + count_str += '%-4d]' % self.Counts.Max + count_str += ' (%2d%%)' % (float(self.Counts.Current) / self.Counts.Max * 100) + log_title = '!' if content else '' + log_title += 'UPDATING NOTE%s: %-80s: %s' % (count_str, self.Fields[FIELDS.TITLE], + self.Fields[FIELDS.EVERNOTE_GUID].replace( + FIELDS.EVERNOTE_GUID_PREFIX, '')) + log(log_title, 'AddUpdateNote', timestamp=(content is ''), + clear=((self.Counts.Current == 1 or self.Counts.Current == 100) and not self.logged)) + self.logged = True + if not content: return + content = obj2log_simple(content) + content = content.replace('\n', '\n ') + log(' > %s\n' % content, 'AddUpdateNote', timestamp=False) + + def update_note_tags(self): + if len(self.Tags) == 0: return False + self.Tags = get_tag_names_to_import(self.Tags) + if not self.BaseNote: + self.log_update("Error with unt") + self.log_update(self.Tags) + self.log_update(self.Fields) + self.log_update(self.BaseNote) + assert self.BaseNote + baseTags = sorted(self.BaseNote.tags, key=lambda s: s.lower()) + value = u','.join(self.Tags) + value_original = u','.join(baseTags) + if str(value) == str(value_original): + return False + self.log_update("Changing tags:\n From: '%s' \n To: '%s'" % (value_original, value)) + self.BaseNote.tags = self.Tags + self.Changed = True + return True + + def update_note_deck(self): + deckNameNew = self.deck() + if not deckNameNew: return False + deckIDNew = self.Anki.decks().id(deckNameNew) + deckIDOld = get_anki_deck_id_from_note_id(self.note.id) + if deckIDNew == deckIDOld: + return False + self.log_update( + "Changing deck:\n From: '%s' \n To: '%s'" % (self.Anki.decks().nameOrNone(deckIDOld), self.deck())) + # Not sure if this is necessary or Anki does it by itself: + ankDB().execute("UPDATE cards SET did = ? WHERE nid = ?", deckIDNew, self.note.id) + return True + + def update_note_fields(self): + fields_to_update = [FIELDS.TITLE, FIELDS.CONTENT, FIELDS.SEE_ALSO, FIELDS.UPDATE_SEQUENCE_NUM] + fld_content_ord = -1 + flag_changed = False + field_updates = [] + fields_updated = {} + for fld in self.note._model['flds']: + if FIELDS.EVERNOTE_GUID in fld.get('name'): + self.OriginalGuid = self.note.fields[fld.get('ord')].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') + for field_to_update in fields_to_update: + if field_to_update == fld.get('name') and field_to_update in self.Fields: + if field_to_update is FIELDS.CONTENT: + fld_content_ord = fld.get('ord') + try: + value = self.Fields[field_to_update] + value_original = self.note.fields[fld.get('ord')] + if isinstance(value, str): + value = unicode(value, 'utf-8') + if isinstance(value_original, str): + value_original = unicode(value_original, 'utf-8') + if not value == value_original: + flag_changed = True + self.note.fields[fld.get('ord')] = value + fields_updated[field_to_update] = value_original + if field_to_update is FIELDS.CONTENT or field_to_update is FIELDS.SEE_ALSO: + diff = generate_diff(value_original, value) + else: + diff = 'From: \n%s \n\n To: \n%s' % (value_original, value) + field_updates.append("Changing field #%d %s:\n%s" % (fld.get('ord'), field_to_update, diff)) + except: + self.log_update(field_updates) + log_error( + "ERROR: UPDATE_NOTE: Note '%s': %s: Unable to set self.note.fields for field '%s'. Ord: %s. Note fields count: %d" % ( + self.Guid, self.Fields[FIELDS.TITLE], field_to_update, str(fld.get('ord')), + len(self.note.fields))) + raise + for update in field_updates: + self.log_update(update) + if flag_changed: self.Changed = True + return flag_changed + + def update_note(self): + self.note = self.BaseNote + self.logged = False + if not self.BaseNote: + self.log_update("Not updating Note: Could not find base note") + return False + self.Changed = False + self.update_note_tags() + self.update_note_fields() + if 'See Also' in self.Fields[FIELDS.CONTENT]: + raise ValueError + if not (self.Changed or self.update_note_deck()): + if self._log_update_if_unchanged_: + self.log_update("Not updating Note: The fields, tags, and deck are the same") + elif ( + self.Counts.Updated is 0 or self.Counts.Current / self.Counts.Updated > 9) and self.Counts.Current % 100 is 0: + self.log_update() + return False + if not self.Changed: + # i.e., the note deck has been changed but the tags and fields have not + self.Counts.Updated += 1 + return True + if not self.OriginalGuid: + flds = get_dict_from_list(self.BaseNote.items()) + self.OriginalGuid = get_evernote_guid_from_anki_fields(flds) + old_title = ankDB().scalar( + "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.OriginalGuid)) + do_log_title=False + new_guid = self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') + new_title = self.Fields[FIELD.TITLE] + old_title = db_title + if !isinstance(new_title, unicode): + try: new_title = unicode(new_title, 'utf-8') + except: do_log_title = True + if !isinstance(old_title, unicode): + try: old_title = unicode(old_title, 'utf-8') + except: do_log_title = True + if do_log_title or new_title != old_title or new_guid != self.OriginalGuid: + log_str = ' %s: DB INFO UNEQUAL: ' % (self.OriginalGuid + ('' if new_guid == self.OriginalGuid else ' vs %s' % new_guid)) + ' ' + new_title + ' vs ' + old_title + log_error(log_str) + self.log_update(log_str) + self.note.flush() + self.update_note_model() + self.Counts.Updated += 1 + return True + + @property + def Title(self): + """:rtype : EvernoteNoteTitle.EvernoteNoteTitle """ + title = "" + if FIELDS.TITLE in self.Fields: + title = self.Fields[FIELDS.TITLE] + if self.BaseNote: + title = self.originalFields[FIELDS.TITLE] + return EvernoteNoteTitle(title) + + def add_note(self): + self.create_note() + if self.note is not None: + collection = self.Anki.collection() + db_title = ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % ( + TABLES.EVERNOTE.NOTES, self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, ''))) + log(' %s: ADD: ' % self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') + ' ' + + self.Fields[FIELDS.TITLE], 'AddUpdateNote') + if self.Fields[FIELDS.TITLE] != db_title: + log(' %s: DB TITLE: ' % re.sub(r'.', ' ', self.Fields[FIELDS.EVERNOTE_GUID].replace( + FIELDS.EVERNOTE_GUID_PREFIX, '')) + ' ' + db_title, 'AddUpdateNote') + try: + collection.addNote(self.note) + except: + log_error("Unable to collection.addNote for Note %s: %s" % ( + self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, ''), db_title)) + log_dump(self.note.fields, '- FAILED collection.addNote: ') + return -1 + collection.autosave() + self.Anki.start_editing() + return self.note.id + + def create_note(self): + id_deck = self.Anki.decks().id(self.deck()) + if not self.ModelName: self.ModelName = MODELS.DEFAULT + model = self.Anki.models().byName(self.ModelName) + col = self.Anki.collection() + self.note = AnkiNote(col, model) + self.note.model()['did'] = id_deck + self.note.tags = self.Tags + for name, value in self.Fields.items(): + self.note[name] = value diff --git a/anknotes/Controller.py b/anknotes/Controller.py index 864ff5f..dcd9ff3 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -3,9 +3,9 @@ import socket try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite ### Anknotes Shared Imports from anknotes.shared import * @@ -14,12 +14,12 @@ ### Anknotes Class Imports from anknotes.AnkiNotePrototype import AnkiNotePrototype from anknotes.EvernoteNoteTitle import generateTOCTitle +from anknotes import stopwatch ### Anknotes Main Imports from anknotes.Anki import Anki from anknotes.ankEvernote import Evernote from anknotes.EvernoteNotes import EvernoteNotes -from anknotes.EvernoteNotePrototype import EvernoteNotePrototype from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher from anknotes import settings from anknotes.EvernoteImporter import EvernoteImporter @@ -36,242 +36,195 @@ class Controller: - evernoteImporter = None - """:type : EvernoteImporter""" - - def __init__(self): - self.forceAutoPage = False - self.auto_page_callback = None - self.anki = Anki() - self.anki.deck = mw.col.conf.get(SETTINGS.DEFAULT_ANKI_DECK, SETTINGS.DEFAULT_ANKI_DECK_DEFAULT_VALUE) - self.anki.setup_ancillary_files() - ankDB().Init() - self.anki.add_evernote_models() - self.evernote = Evernote() - - def test_anki(self, title, evernote_guid, filename=""): - if not filename: filename = title - fields = { - FIELDS.TITLE: title, - FIELDS.CONTENT: file( - os.path.join(ANKNOTES.FOLDER_LOGS, filename.replace('.enex', '') + ".enex"), - 'r').read(), FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid - } - tags = ['NoTags', 'NoTagsToRemove'] - return AnkiNotePrototype(self.anki, fields, tags) - - def process_unadded_see_also_notes(self): - update_regex() - anki_note_ids = self.anki.get_anknotes_note_ids_with_unadded_see_also() - self.evernote.getNoteCount = 0 - self.anki.process_see_also_content(anki_note_ids) - - def upload_validated_notes(self, automated=False): - self.anki.evernoteTags = [] - dbRows = ankDB().all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) - number_updated = number_created = 0 - count = count_create = count_update = 0 - exist = error = 0 - status = EvernoteAPIStatus.Uninitialized - notes_created = [] - """ - :type: list[EvernoteNote] - """ - notes_updated = [] - """ - :type: list[EvernoteNote] - """ - queries1 = [] - queries2 = [] - noteFetcher = EvernoteNoteFetcher() - SIMULATE = False - if len(dbRows) == 0: - if not automated: - show_report(" > Upload of Validated Notes Aborted", "No Qualifying Validated Notes Found") - return - else: - log(" > Upload of Validated Notes Initiated", "%d Successfully Validated Notes Found" % len(dbRows)) - - for dbRow in dbRows: - entry = EvernoteValidationEntry(dbRow) - evernote_guid, rootTitle, contents, tagNames, notebookGuid = entry.items() - tagNames = tagNames.split(',') - if not ANKNOTES.UPLOAD_AUTO_TOC_NOTES or ( - -1 < ANKNOTES.AUTO_TOC_NOTES_MAX <= count_update + count_create): - continue - if SIMULATE: - status = EvernoteAPIStatus.Success - else: - status, whole_note = self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid, - validated=True) - if status.IsError: - error += 1 - if status == EvernoteAPIStatus.RateLimitError or status == EvernoteAPIStatus.SocketError: - break - else: - continue - count += 1 - if status.IsSuccess: - if not SIMULATE: - noteFetcher.addNoteFromServerToDB(whole_note, tagNames) - note = EvernoteNotePrototype(whole_note=whole_note, tags=tagNames) - if evernote_guid: - if not SIMULATE: - notes_updated.append(note) - queries1.append([evernote_guid]) - count_update += 1 - else: - if not SIMULATE: - notes_created.append(note) - queries2.append([rootTitle, contents]) - count_create += 1 - if not SIMULATE and count_update + count_create > 0: - number_updated = self.anki.update_evernote_notes(notes_updated) - number_created = self.anki.add_evernote_notes(notes_created) - count_max = len(dbRows) - - str_tip_header = "%s Validated Note(s) successfully generated." % counts_as_str(count, count_max) - str_tips = [] - if count_create: str_tips.append("%s Validated Note(s) were newly created " % counts_as_str(count_create)) - if number_created: str_tips.append("-%-3d of these were successfully added to Anki " % number_created) - if count_update: str_tips.append("%s Validated Note(s) already exist in local db and were updated" % counts_as_str(count_update)) - if number_updated: str_tips.append("-%-3d of these were successfully updated in Anki " % number_updated) - if error > 0: str_tips.append("%d Error(s) occurred " % error) - show_report(" > Upload of Validated Notes Complete", str_tip_header, str_tips) - - if len(queries1) + len(queries2) > 0: - if len(queries1) > 0: - ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.MAKE_NOTE_QUEUE, queries1) - if len(queries2) > 0: - ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.MAKE_NOTE_QUEUE, queries2) - ankDB().commit() - - return status, count, exist - - def create_auto_toc(self): - update_regex() - self.anki.evernoteTags = [] - NotesDB = EvernoteNotes() - NotesDB.baseQuery = ANKNOTES.ROOT_TITLES_BASE_QUERY - dbRows = NotesDB.populateAllNonCustomRootNotes() - # dbRows = NoteDB.populateAllPotentialRootNotes() - number_updated = number_created = 0 - count = count_create = count_update = count_update_skipped = 0 - count_queued = count_queued_create = count_queued_update = 0 - exist = error = 0 - status = EvernoteAPIStatus.Uninitialized - notes_created = [] - """ - :type: list[EvernoteNote] - """ - notes_updated = [] - """ - :type: list[EvernoteNote] - """ - if len(dbRows) == 0: - show_report(" > TOC Creation Aborted", 'No Qualifying Root Titles Found') - return - for dbRow in dbRows: - rootTitle, contents, tagNames, notebookGuid = dbRow.items() - tagNames = tagNames[1:-1].split(',') - if EVERNOTE.TAG.REVERSIBLE in tagNames: - tagNames.remove(EVERNOTE.TAG.REVERSIBLE) - if EVERNOTE.TAG.REVERSE_ONLY in tagNames: - tagNames.remove(EVERNOTE.TAG.REVERSE_ONLY) - tagNames.append(EVERNOTE.TAG.TOC) - tagNames.append(EVERNOTE.TAG.AUTO_TOC) - if ANKNOTES.EVERNOTE_IS_SANDBOXED: - tagNames.append("#Sandbox") - rootTitle = generateTOCTitle(rootTitle) - old_values = ankDB().first( - "SELECT guid, content FROM %s WHERE UPPER(title) = ? AND tagNames LIKE '%%,' || ? || ',%%'" % TABLES.EVERNOTE.NOTES, - rootTitle.upper(), EVERNOTE.TAG.AUTO_TOC) - evernote_guid = None - # noteBody = self.evernote.makeNoteBody(contents, encode=True) - - noteBodyUnencoded = self.evernote.makeNoteBody(contents, encode=False) - if old_values: - evernote_guid, old_content = old_values - if type(old_content) != type(noteBodyUnencoded): - log([rootTitle, type(old_content), type(noteBodyUnencoded)], 'AutoTOC-Create-Diffs\\_') - raise UnicodeWarning - old_content = old_content.replace('guid-pending', evernote_guid) - noteBodyUnencoded = noteBodyUnencoded.replace('guid-pending', evernote_guid) - if old_content == noteBodyUnencoded: - count += 1 - count_update_skipped += 1 - continue - contents = contents.replace('/guid-pending/', '/%s/' % evernote_guid).replace('/guid-pending/', '/%s/' % evernote_guid) - log(noteBodyUnencoded, 'AutoTOC-Create-New\\'+rootTitle, clear=True) - log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create-Diffs\\'+rootTitle, clear=True) - if not ANKNOTES.UPLOAD_AUTO_TOC_NOTES or ( - -1 < ANKNOTES.AUTO_TOC_NOTES_MAX <= count_update + count_create): - continue - status, whole_note = self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid) - if status.IsError: - error += 1 - if status == EvernoteAPIStatus.RateLimitError or status == EvernoteAPIStatus.SocketError: - break - else: - continue - if status == EvernoteAPIStatus.RequestQueued: - count_queued += 1 - if old_values: count_queued_update += 1 - else: count_queued_create += 1 - continue - count += 1 - if status.IsSuccess: - note = EvernoteNotePrototype(whole_note=whole_note, tags=tagNames) - if evernote_guid: - notes_updated.append(note) - count_update += 1 - else: - notes_created.append(note) - count_create += 1 - if count_update + count_create > 0: - number_updated = self.anki.update_evernote_notes(notes_updated) - number_created = self.anki.add_evernote_notes(notes_created) - count_total = count + count_queued - count_max = len(dbRows) - str_tip_header = "%s Auto TOC note(s) successfully generated" % counts_as_str(count_total, count_max) - str_tips = [] - if count_create: str_tips.append("%-3d Auto TOC note(s) were newly created " % count_create) - if number_created: str_tips.append("-%d of these were successfully added to Anki " % number_created) - if count_queued_create: str_tips.append("-%s Auto TOC note(s) are brand new and and were queued to be added to Anki " % counts_as_str(count_queued_create)) - if count_update: str_tips.append("%-3d Auto TOC note(s) already exist in local db and were updated" % count_update) - if number_updated: str_tips.append("-%s of these were successfully updated in Anki " % counts_as_str(number_updated)) - if count_queued_update: str_tips.append("-%s Auto TOC note(s) already exist in local db and were queued to be updated in Anki" % counts_as_str(count_queued_update)) - if count_update_skipped: str_tips.append("-%s Auto TOC note(s) already exist in local db and were unchanged" % counts_as_str(count_update_skipped)) - if error > 0: str_tips.append("%d Error(s) occurred " % error) - show_report(" > TOC Creation Complete: ", str_tip_header, str_tips) - - if count_queued > 0: - ankDB().commit() - - return status, count, exist - - def update_ancillary_data(self): - self.evernote.update_ancillary_data() - - def proceed(self, auto_paging=False): - if not self.evernoteImporter: - self.evernoteImporter = EvernoteImporter() - self.evernoteImporter.anki = self.anki - self.evernoteImporter.evernote = self.evernote - self.evernoteImporter.forceAutoPage = self.forceAutoPage - self.evernoteImporter.auto_page_callback = self.auto_page_callback - if not hasattr(self, 'currentPage'): - self.currentPage = 1 - self.evernoteImporter.currentPage = self.currentPage - if hasattr(self, 'ManualGUIDs'): - self.evernoteImporter.ManualGUIDs = self.ManualGUIDs - self.evernoteImporter.proceed(auto_paging) - - def resync_with_local_db(self): - evernote_guids = get_all_local_db_guids() - results = self.evernote.create_evernote_notes(evernote_guids, use_local_db_only=True) - """:type: EvernoteNoteFetcherResults""" - number = self.anki.update_evernote_notes(results.Notes, log_update_if_unchanged=False) - tooltip = '%d Entries in Local DB<BR>%d Evernote Notes Created<BR>%d Anki Notes Successfully Updated' % ( - len(evernote_guids), results.Local, number) - show_report('Resync with Local DB Complete', tooltip) + evernoteImporter = None + """:type : EvernoteImporter""" + + def __init__(self): + self.forceAutoPage = False + self.auto_page_callback = None + self.anki = Anki() + self.anki.deck = mw.col.conf.get(SETTINGS.ANKI.DECKS.BASE, SETTINGS.ANKI.DECKS.BASE_DEFAULT_VALUE) + self.anki.setup_ancillary_files() + ankDB().Init() + self.anki.add_evernote_models() + self.evernote = Evernote() + + def test_anki(self, title, evernote_guid, filename=""): + if not filename: filename = title + fields = { + FIELDS.TITLE: title, + FIELDS.CONTENT: file( + os.path.join(FOLDERS.LOGS, filename.replace('.enex', '') + ".enex"), + 'r').read(), FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid + } + tags = ['NoTags', 'NoTagsToRemove'] + return AnkiNotePrototype(self.anki, fields, tags) + + def process_unadded_see_also_notes(self): + update_regex() + anki_note_ids = self.anki.get_anknotes_note_ids_with_unadded_see_also() + self.evernote.getNoteCount = 0 + self.anki.process_see_also_content(anki_note_ids) + + def upload_validated_notes(self, automated=False): + dbRows = ankDB().all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.NOTE_VALIDATION_QUEUE) + retry=True + notes_created, notes_updated, queries1, queries2 = ([] for i in range(4)) + """ + :type: (list[EvernoteNote], list[EvernoteNote], list[str], list[str]) + """ + noteFetcher = EvernoteNoteFetcher() + tmr = stopwatch.Timer(len(dbRows), 25, "Upload of Validated Evernote Notes") + if tmr.actionInitializationFailed: return tmr.status, 0, 0 + if not EVERNOTE.UPLOAD.ENABLED: + tmr.info.ActionLine("Aborted", "EVERNOTE.UPLOAD.ENABLED is set to False") + return EvernoteAPIStatus.Disabled + for dbRow in dbRows: + entry = EvernoteValidationEntry(dbRow) + evernote_guid, rootTitle, contents, tagNames, notebookGuid = entry.items() + tagNames = tagNames.split(',') + if -1 < EVERNOTE.UPLOAD.MAX <= count_update + count_create: + tmr.reportStatus(EvernoteAPIStatus.DelayedDueToRateLimit if EVERNOTE.UPLOAD.RESTART_INTERVAL > 0 else EvernoteAPIStatus.ExceededLocalLimit) + log("upload_validated_notes: Count exceeded- Breaking with status " + str(tmr.status)) + break + whole_note = tmr.autoStep(self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid, validated=True), rootTitle, evernote_guid) + if tmr.report_result == False: raise ValueError + if tmr.status.IsDelayableError: + log("upload_validated_notes: Delayable error - breaking with status " + str(tmr.status)) + break + if not tmr.status.IsSuccess: continue + if not whole_note.tagNames: whole_note.tagNames = tagNames + noteFetcher.addNoteFromServerToDB(whole_note, tagNames) + note = EvernoteNotePrototype(whole_note=whole_note) + assert whole_note.tagNames + assert note.Tags + if evernote_guid: + notes_updated.append(note) + queries1.append([evernote_guid]) + else: + notes_created.append(note) + queries2.append([rootTitle, contents]) + else: + retry=False + log("upload_validated_notes: Did not break out of for loop") + log("upload_validated_notes: Outside of the for loop ") + + tmr.Report(self.anki.add_evernote_notes(notes_created) if tmr.count_created else 0, self.anki.update_evernote_notes(notes_updated) if tmr.count_updated else 0) + if tmr.subcount_created: ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries2) + if tmr.subcount_updated: ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries1) + if tmr.is_success: ankDB().commit() + if retry and tmr.status != EvernoteAPIStatus.ExceededLocalLimit: mw.progress.timer((30 if tmr.status.IsDelayableError else EVERNOTE.UPLOAD.RESTART_INTERVAL) * 1000, lambda: self.upload_validated_notes(True), False) + return tmr.status, tmr.count, 0 + + def create_auto_toc(self): + update_regex() + NotesDB = EvernoteNotes() + NotesDB.baseQuery = ANKNOTES.HIERARCHY.ROOT_TITLES_BASE_QUERY + dbRows = NotesDB.populateAllNonCustomRootNotes() + # number_updated = number_created = 0 + # count = count_create = count_update = count_update_skipped = 0 + # count_queued = count_queued_create = count_queued_update = 0 + # exist = error = 0 + # status = EvernoteAPIStatus.Uninitialized + notes_created, notes_updated = [], [] + """ + :type: (list[EvernoteNote], list[EvernoteNote]) + """ + info = stopwatch.ActionInfo('Creation of Table of Content Note(s)', row_source='Root Title(s)') + tmr = stopwatch.Timer(len(dbRows), 25, info) + if tmr.actionInitializationFailed: return tmr.status, 0, 0 + for dbRow in dbRows: + rootTitle, contents, tagNames, notebookGuid = dbRow.items() + tagNames = (set(tagNames[1:-1].split(',')) | {TAGS.TOC, TAGS.AUTO_TOC} | ({"#Sandbox"} if EVERNOTE.API.IS_SANDBOXED else set())) - {TAGS.REVERSIBLE, TAGS.REVERSE_ONLY} + rootTitle = generateTOCTitle(rootTitle) + old_values = ankDB().first( + "SELECT guid, content FROM %s WHERE UPPER(title) = ? AND tagNames LIKE '%%,' || ? || ',%%'" % TABLES.EVERNOTE.NOTES, + rootTitle.upper(), TAGS.AUTO_TOC) + evernote_guid = None + noteBodyUnencoded = self.evernote.makeNoteBody(contents, encode=False) + if old_values: + evernote_guid, old_content = old_values + if type(old_content) != type(noteBodyUnencoded): + log([rootTitle, type(old_content), type(noteBodyUnencoded)], 'AutoTOC-Create-Diffs\\_') + raise UnicodeWarning + old_content = old_content.replace('guid-pending', evernote_guid) + noteBodyUnencoded = noteBodyUnencoded.replace('guid-pending', evernote_guid) + if old_content == noteBodyUnencoded: + tmr.report + count += 1 + count_update_skipped += 1 + continue + contents = contents.replace('/guid-pending/', '/%s/' % evernote_guid).replace('/guid-pending/', '/%s/' % evernote_guid) + log(noteBodyUnencoded, 'AutoTOC-Create-New\\'+rootTitle, clear=True) + log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create-Diffs\\'+rootTitle, clear=True) + if not EVERNOTE.UPLOAD.ENABLED or ( + -1 < EVERNOTE.UPLOAD.MAX <= count_update + count_create): + continue + status, whole_note = self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid) + if status.IsError: + error += 1 + if status == EvernoteAPIStatus.RateLimitError or status == EvernoteAPIStatus.SocketError: + break + else: + continue + if status == EvernoteAPIStatus.RequestQueued: + count_queued += 1 + if old_values: count_queued_update += 1 + else: count_queued_create += 1 + continue + count += 1 + if status.IsSuccess: + note = EvernoteNotePrototype(whole_note=whole_note) + if evernote_guid: + notes_updated.append(note) + count_update += 1 + else: + notes_created.append(note) + count_create += 1 + if count_update + count_create > 0: + number_updated = self.anki.update_evernote_notes(notes_updated) + number_created = self.anki.add_evernote_notes(notes_created) + count_total = count + count_queued + count_max = len(dbRows) + str_tip_header = "%s Auto TOC note(s) successfully generated" % counts_as_str(count_total, count_max) + str_tips = [] + if count_create: str_tips.append("%-3d Auto TOC note(s) were newly created " % count_create) + if number_created: str_tips.append("-%d of these were successfully added to Anki " % number_created) + if count_queued_create: str_tips.append("-%s Auto TOC note(s) are brand new and and were queued to be added to Anki " % counts_as_str(count_queued_create)) + if count_update: str_tips.append("%-3d Auto TOC note(s) already exist in local db and were updated" % count_update) + if number_updated: str_tips.append("-%s of these were successfully updated in Anki " % counts_as_str(number_updated)) + if count_queued_update: str_tips.append("-%s Auto TOC note(s) already exist in local db and were queued to be updated in Anki" % counts_as_str(count_queued_update)) + if count_update_skipped: str_tips.append("-%s Auto TOC note(s) already exist in local db and were unchanged" % counts_as_str(count_update_skipped)) + if error > 0: str_tips.append("%d Error(s) occurred " % error) + show_report(" > TOC Creation Complete: ", str_tip_header, str_tips) + + if count_queued > 0: + ankDB().commit() + + return status, count, exist + + def update_ancillary_data(self): + self.evernote.update_ancillary_data() + + def proceed(self, auto_paging=False): + if not self.evernoteImporter: + self.evernoteImporter = EvernoteImporter() + self.evernoteImporter.anki = self.anki + self.evernoteImporter.evernote = self.evernote + self.evernoteImporter.forceAutoPage = self.forceAutoPage + self.evernoteImporter.auto_page_callback = self.auto_page_callback + if not hasattr(self, 'currentPage'): + self.currentPage = 1 + self.evernoteImporter.currentPage = self.currentPage + if hasattr(self, 'ManualGUIDs'): + self.evernoteImporter.ManualGUIDs = self.ManualGUIDs + self.evernoteImporter.proceed(auto_paging) + + def resync_with_local_db(self): + evernote_guids = get_all_local_db_guids() + results = self.evernote.create_evernote_notes(evernote_guids, use_local_db_only=True) + """:type: EvernoteNoteFetcherResults""" + show_report('Resync with Local DB: Starting Anki Update of %d Note(s)' % len(evernote_guids)) + number = self.anki.update_evernote_notes(results.Notes, log_update_if_unchanged=False) + tooltip = '%d Evernote Notes Created<BR>%d Anki Notes Successfully Updated' % (results.Local, number) + show_report('Resync with Local DB Complete') diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index 5980ca3..929ac12 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -3,9 +3,9 @@ import socket try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite ### Anknotes Shared Imports from anknotes.shared import * @@ -36,274 +36,275 @@ class EvernoteImporter: - forceAutoPage = False - auto_page_callback = None - """:type : lambda""" - anki = None - """:type : Anki""" - evernote = None - """:type : Evernote""" - updateExistingNotes = UpdateExistingNotes.UpdateNotesInPlace - ManualGUIDs = None - @property - def ManualMetadataMode(self): - return (self.ManualGUIDs is not None and len(self.ManualGUIDs) > 0) + forceAutoPage = False + auto_page_callback = None + """:type : lambda""" + anki = None + """:type : Anki""" + evernote = None + """:type : Evernote""" + updateExistingNotes = UpdateExistingNotes.UpdateNotesInPlace + ManualGUIDs = None + @property + def ManualMetadataMode(self): + return (self.ManualGUIDs is not None and len(self.ManualGUIDs) > 0) - def __init(self): - self.updateExistingNotes = mw.col.conf.get(SETTINGS.UPDATE_EXISTING_NOTES, - UpdateExistingNotes.UpdateNotesInPlace) - self.ManualGUIDs = None + def __init(self): + self.updateExistingNotes = mw.col.conf.get(SETTINGS.ANKI.UPDATE_EXISTING_NOTES, + UpdateExistingNotes.UpdateNotesInPlace) + self.ManualGUIDs = None - def override_evernote_metadata(self): - guids = self.ManualGUIDs - self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) - self.MetadataProgress.Total = len(guids) - self.MetadataProgress.Current = min(self.MetadataProgress.Total - self.MetadataProgress.Offset, 250) - result = NotesMetadataList() - result.totalNotes = len(guids) - result.updateCount = -1 - result.startIndex = self.MetadataProgress.Offset - result.notes = [] - """:type : list[NoteMetadata]""" - for i in range(self.MetadataProgress.Offset, self.MetadataProgress.Completed): - result.notes.append(NoteMetadata(guids[i])) - self.MetadataProgress.loadResults(result) - self.evernote.metadata = self.MetadataProgress.NotesMetadata - return True + def override_evernote_metadata(self): + guids = self.ManualGUIDs + self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) + self.MetadataProgress.Total = len(guids) + self.MetadataProgress.Current = min(self.MetadataProgress.Total - self.MetadataProgress.Offset, 250) + result = NotesMetadataList() + result.totalNotes = len(guids) + result.updateCount = -1 + result.startIndex = self.MetadataProgress.Offset + result.notes = [] + """:type : list[NoteMetadata]""" + for i in range(self.MetadataProgress.Offset, self.MetadataProgress.Completed): + result.notes.append(NoteMetadata(guids[i])) + self.MetadataProgress.loadResults(result) + self.evernote.metadata = self.MetadataProgress.NotesMetadata + return True - def get_evernote_metadata(self): - """ - :returns: Metadata Progress Instance - :rtype : EvernoteMetadataProgress) - """ - query = settings.generate_evernote_query() - evernote_filter = NoteFilter(words=query, ascending=True, order=NoteSortOrder.UPDATED) - self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) - spec = NotesMetadataResultSpec(includeTitle=False, includeUpdated=False, includeUpdateSequenceNum=True, - includeTagGuids=True, includeNotebookGuid=True) - api_action_str = u'trying to search for note metadata' - log_api("findNotesMetadata", "[Offset: %3d]: Query: '%s'" % (self.MetadataProgress.Offset, query)) - try: - result = self.evernote.noteStore.findNotesMetadata(self.evernote.token, evernote_filter, - self.MetadataProgress.Offset, - EVERNOTE.METADATA_QUERY_LIMIT, spec) - """ - :type: NotesMetadataList - """ - except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - self.MetadataProgress.Status = EvernoteAPIStatus.RateLimitError - return False - raise - except socket.error, v: - if HandleSocketError(v, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - self.MetadataProgress.Status = EvernoteAPIStatus.SocketError - return False - raise + def get_evernote_metadata(self): + """ + :returns: Metadata Progress Instance + :rtype : EvernoteMetadataProgress) + """ + query = settings.generate_evernote_query() + evernote_filter = NoteFilter(words=query, ascending=True, order=NoteSortOrder.UPDATED) + self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) + spec = NotesMetadataResultSpec(includeTitle=False, includeUpdated=False, includeUpdateSequenceNum=True, + includeTagGuids=True, includeNotebookGuid=True) + self.evernote.initialize_note_store() + api_action_str = u'trying to search for note metadata' + log_api("findNotesMetadata", "[Offset: %3d]: Query: '%s'" % (self.MetadataProgress.Offset, query)) + try: + result = self.evernote.noteStore.findNotesMetadata(self.evernote.token, evernote_filter, + self.MetadataProgress.Offset, + EVERNOTE.IMPORT.METADATA_RESULTS_LIMIT, spec) + """ + :type: NotesMetadataList + """ + except EDAMSystemException as e: + if HandleEDAMRateLimitError(e, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + self.MetadataProgress.Status = EvernoteAPIStatus.RateLimitError + return False + raise + except socket.error, v: + if HandleSocketError(v, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + self.MetadataProgress.Status = EvernoteAPIStatus.SocketError + return False + raise - self.MetadataProgress.loadResults(result) - self.evernote.metadata = self.MetadataProgress.NotesMetadata - log(" - Metadata Results: %s" % self.MetadataProgress.Summary, timestamp=False) - return True + self.MetadataProgress.loadResults(result) + self.evernote.metadata = self.MetadataProgress.NotesMetadata + log(" - Metadata Results: %s" % self.MetadataProgress.Summary, timestamp=False) + return True - def update_in_anki(self, evernote_guids): - """ - :rtype : EvernoteNoteFetcherResults - """ - Results = self.evernote.create_evernote_notes(evernote_guids) - if self.ManualMetadataMode: - self.evernote.check_notebooks_up_to_date() - self.anki.notebook_data = self.evernote.notebook_data - Results.Imported = self.anki.update_evernote_notes(Results.Notes) - return Results + def update_in_anki(self, evernote_guids): + """ + :rtype : EvernoteNoteFetcherResults + """ + Results = self.evernote.create_evernote_notes(evernote_guids) + if self.ManualMetadataMode: + self.evernote.check_notebooks_up_to_date() + self.anki.notebook_data = self.evernote.notebook_data + Results.Imported = self.anki.update_evernote_notes(Results.Notes) + return Results - def import_into_anki(self, evernote_guids): - """ - :rtype : EvernoteNoteFetcherResults - """ - Results = self.evernote.create_evernote_notes(evernote_guids) - if self.ManualMetadataMode: - self.evernote.check_notebooks_up_to_date() - self.anki.notebook_data = self.evernote.notebook_data - Results.Imported = self.anki.add_evernote_notes(Results.Notes) - return Results + def import_into_anki(self, evernote_guids): + """ + :rtype : EvernoteNoteFetcherResults + """ + Results = self.evernote.create_evernote_notes(evernote_guids) + if self.ManualMetadataMode: + self.evernote.check_notebooks_up_to_date() + self.anki.notebook_data = self.evernote.notebook_data + Results.Imported = self.anki.add_evernote_notes(Results.Notes) + return Results - def check_note_sync_status(self, evernote_guids): - """ - Check for already existing, up-to-date, local db entries by Evernote GUID - :param evernote_guids: List of GUIDs - :return: List of Already Existing Evernote GUIDs - :rtype: list[str] - """ - notes_already_up_to_date = [] - for evernote_guid in evernote_guids: - db_usn = ankDB().scalar("SELECT updateSequenceNum FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, - evernote_guid) - if not self.evernote.metadata[evernote_guid].updateSequenceNum: - server_usn = 'N/A' - else: - server_usn = self.evernote.metadata[evernote_guid].updateSequenceNum - if evernote_guid in self.anki.usns: - current_usn = self.anki.usns[evernote_guid] - if current_usn == str(server_usn): - log_info = None # 'ANKI NOTE UP-TO-DATE' - notes_already_up_to_date.append(evernote_guid) - elif str(db_usn) == str(server_usn): - log_info = 'DATABASE ENTRY UP-TO-DATE' - else: - log_info = 'NO COPIES UP-TO-DATE' - else: - current_usn = 'N/A' - log_info = 'NO ANKI USN EXISTS' - if log_info: - if not self.evernote.metadata[evernote_guid].updateSequenceNum: - log_info += ' (Unable to find Evernote Metadata) ' - log(" > USN check for note '%s': %s: db/current/server = %s,%s,%s" % ( - evernote_guid, log_info, str(db_usn), str(current_usn), str(server_usn)), 'usn') - return notes_already_up_to_date + def check_note_sync_status(self, evernote_guids): + """ + Check for already existing, up-to-date, local db entries by Evernote GUID + :param evernote_guids: List of GUIDs + :return: List of Already Existing Evernote GUIDs + :rtype: list[str] + """ + notes_already_up_to_date = [] + for evernote_guid in evernote_guids: + db_usn = ankDB().scalar("SELECT updateSequenceNum FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, + evernote_guid) + if not self.evernote.metadata[evernote_guid].updateSequenceNum: + server_usn = 'N/A' + else: + server_usn = self.evernote.metadata[evernote_guid].updateSequenceNum + if evernote_guid in self.anki.usns: + current_usn = self.anki.usns[evernote_guid] + if current_usn == str(server_usn): + log_info = None # 'ANKI NOTE UP-TO-DATE' + notes_already_up_to_date.append(evernote_guid) + elif str(db_usn) == str(server_usn): + log_info = 'DATABASE ENTRY UP-TO-DATE' + else: + log_info = 'NO COPIES UP-TO-DATE' + else: + current_usn = 'N/A' + log_info = 'NO ANKI USN EXISTS' + if log_info: + if not self.evernote.metadata[evernote_guid].updateSequenceNum: + log_info += ' (Unable to find Evernote Metadata) ' + log(" > USN check for note '%s': %s: db/current/server = %s,%s,%s" % ( + evernote_guid, log_info, str(db_usn), str(current_usn), str(server_usn)), 'usn') + return notes_already_up_to_date - def proceed(self, auto_paging=False): - self.proceed_start(auto_paging) - self.proceed_find_metadata(auto_paging) - self.proceed_import_notes() - self.proceed_autopage() + def proceed(self, auto_paging=False): + self.proceed_start(auto_paging) + self.proceed_find_metadata(auto_paging) + self.proceed_import_notes() + self.proceed_autopage() - def proceed_start(self, auto_paging=False): - col = self.anki.collection() - lastImport = col.conf.get(SETTINGS.EVERNOTE_LAST_IMPORT, None) - col.conf[SETTINGS.EVERNOTE_LAST_IMPORT] = datetime.now().strftime(ANKNOTES.DATE_FORMAT) - col.setMod() - col.save() - lastImportStr = get_friendly_interval_string(lastImport) - if lastImportStr: - lastImportStr = ' [LAST IMPORT: %s]' % lastImportStr - log("! > Starting Evernote Import: Page #%d: %-60s%s" % ( - self.currentPage, settings.generate_evernote_query(), lastImportStr)) - log( - "-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------", - timestamp=False) - if not auto_paging: - if not hasattr(self.evernote, 'noteStore'): - log(" > Note store does not exist. Aborting.") - return False - self.evernote.getNoteCount = 0 + def proceed_start(self, auto_paging=False): + col = self.anki.collection() + lastImport = col.conf.get(SETTINGS.EVERNOTE.LAST_IMPORT, None) + col.conf[SETTINGS.EVERNOTE.LAST_IMPORT] = datetime.now().strftime(ANKNOTES.DATE_FORMAT) + col.setMod() + col.save() + lastImportStr = get_friendly_interval_string(lastImport) + if lastImportStr: + lastImportStr = ' [LAST IMPORT: %s]' % lastImportStr + log("! > Starting Evernote Import: Page #%d: %-60s%s" % ( + self.currentPage, settings.generate_evernote_query(), lastImportStr)) + log( + "-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------", + timestamp=False) + if not auto_paging: + if not hasattr(self.evernote, 'noteStore'): + log(" > Note store does not exist. Aborting.") + return False + self.evernote.getNoteCount = 0 - def proceed_find_metadata(self, auto_paging=False): - global latestEDAMRateLimit, latestSocketError + def proceed_find_metadata(self, auto_paging=False): + global latestEDAMRateLimit, latestSocketError - # anki_note_ids = self.anki.get_anknotes_note_ids() - # anki_evernote_guids = self.anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) + # anki_note_ids = self.anki.get_anknotes_note_ids() + # anki_evernote_guids = self.anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) - if self.ManualMetadataMode: - self.override_evernote_metadata() - else: - self.get_evernote_metadata() - if self.MetadataProgress.Status == EvernoteAPIStatus.RateLimitError: - m, s = divmod(latestEDAMRateLimit, 60) - show_report(" > Error: Delaying Operation", - "Over the rate limit when searching for Evernote metadata<BR>Evernote requested we wait %d:%02d min" % ( - m, s), delay=5) - mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(auto_paging), False) - return False - elif self.MetadataProgress.Status == EvernoteAPIStatus.SocketError: - show_report(" > Error: Delaying Operation:", - "%s when searching for Evernote metadata" % - latestSocketError['friendly_error_msg'], "We will try again in 30 seconds", delay=5) - mw.progress.timer(30000, lambda: self.proceed(auto_paging), False) - return False + if self.ManualMetadataMode: + self.override_evernote_metadata() + else: + self.get_evernote_metadata() + if self.MetadataProgress.Status == EvernoteAPIStatus.RateLimitError: + m, s = divmod(latestEDAMRateLimit, 60) + show_report(" > Error: Delaying Operation", + "Over the rate limit when searching for Evernote metadata<BR>Evernote requested we wait %d:%02d min" % ( + m, s), delay=5) + mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(auto_paging), False) + return False + elif self.MetadataProgress.Status == EvernoteAPIStatus.SocketError: + show_report(" > Error: Delaying Operation:", + "%s when searching for Evernote metadata" % + latestSocketError['friendly_error_msg'], "We will try again in 30 seconds", delay=5) + mw.progress.timer(30000, lambda: self.proceed(auto_paging), False) + return False - self.ImportProgress = EvernoteImportProgress(self.anki, self.MetadataProgress) - self.ImportProgress.loadAlreadyUpdated( - [] if self.ManualMetadataMode else self.check_note_sync_status(self.ImportProgress.GUIDs.Server.Existing.All)) - log(" - " + self.ImportProgress.Summary + "\n", timestamp=False) + self.ImportProgress = EvernoteImportProgress(self.anki, self.MetadataProgress) + self.ImportProgress.loadAlreadyUpdated( + [] if self.ManualMetadataMode else self.check_note_sync_status(self.ImportProgress.GUIDs.Server.Existing.All)) + log(" - " + self.ImportProgress.Summary + "\n", timestamp=False) - def proceed_import_notes(self): - self.anki.start_editing() - self.ImportProgress.processResults(self.import_into_anki(self.ImportProgress.GUIDs.Server.New)) - if self.updateExistingNotes is UpdateExistingNotes.UpdateNotesInPlace: - self.ImportProgress.processUpdateInPlaceResults( - self.update_in_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) - elif self.updateExistingNotes is UpdateExistingNotes.DeleteAndReAddNotes: - self.anki.delete_anki_cards(self.ImportProgress.GUIDs.Server.Existing.OutOfDate) - self.ImportProgress.processDeleteAndUpdateResults( - self.import_into_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) - show_report(" > Import Complete", self.ImportProgress.ResultsSummaryLines) - self.anki.stop_editing() - self.anki.collection().autosave() + def proceed_import_notes(self): + self.anki.start_editing() + self.ImportProgress.processResults(self.import_into_anki(self.ImportProgress.GUIDs.Server.New)) + if self.updateExistingNotes is UpdateExistingNotes.UpdateNotesInPlace: + self.ImportProgress.processUpdateInPlaceResults( + self.update_in_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) + elif self.updateExistingNotes is UpdateExistingNotes.DeleteAndReAddNotes: + self.anki.delete_anki_cards(self.ImportProgress.GUIDs.Server.Existing.OutOfDate) + self.ImportProgress.processDeleteAndUpdateResults( + self.import_into_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) + show_report(" > Import Complete", self.ImportProgress.ResultsSummaryLines) + self.anki.stop_editing() + self.anki.collection().autosave() - def proceed_autopage(self): - if not self.autoPagingEnabled: - return - global latestEDAMRateLimit, latestSocketError - col = self.anki.collection() - status = self.ImportProgress.Status - if status == EvernoteAPIStatus.RateLimitError: - m, s = divmod(latestEDAMRateLimit, 60) - show_report(" > Error: Delaying Auto Paging", - "Over the rate limit when getting Evernote notes<BR>Evernote requested we wait %d:%02d min" % ( - m, s), delay=5) - mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(True), False) - return False - if status == EvernoteAPIStatus.SocketError: - show_report(" > Error: Delaying Auto Paging:", - "%s when getting Evernote notes" % latestSocketError[ - 'friendly_error_msg'], - "We will try again in 30 seconds", delay=5) - mw.progress.timer(30000, lambda: self.proceed(True), False) - return False - if self.MetadataProgress.IsFinished: - self.currentPage = 1 - if self.forceAutoPage: - show_report(" > Terminating Auto Paging", - "All %d notes have been processed and forceAutoPage is True" % self.MetadataProgress.Total, - delay=5) - if self.auto_page_callback: - self.auto_page_callback() - return True - elif col.conf.get(EVERNOTE.PAGING_RESTART_WHEN_COMPLETE, True): - restart = max(EVERNOTE.PAGING_RESTART_INTERVAL, 60*15) - restart_title = " > Restarting Auto Paging" - restart_msg = "All %d notes have been processed and EVERNOTE.PAGING_RESTART_WHEN_COMPLETE is TRUE<BR>" % \ - self.MetadataProgress.Total - suffix = "Per EVERNOTE.PAGING_RESTART_INTERVAL, " - else: - show_report(" > Completed Auto Paging", - "All %d notes have been processed and EVERNOTE.PAGING_RESTART_WHEN_COMPLETE is FALSE" % - self.MetadataProgress.Total, delay=5) - return True - else: # Paging still in progress - self.currentPage = self.MetadataProgress.Page + 1 - restart_title = " > Continuing Auto Paging" - restart_msg = "Page %d completed<BR>%d notes remain<BR>%d of %d notes have been processed" % ( - self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.Completed, - self.MetadataProgress.Total) - restart = 0 - if self.forceAutoPage: - suffix = "<BR>Not delaying as the forceAutoPage flag is set" - elif self.ImportProgress.APICallCount < EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS: - suffix = "<BR>Not delaying as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS" % ( - self.ImportProgress.APICallCount, EVERNOTE.PAGING_RESTART_DELAY_MINIMUM_API_CALLS) - else: - restart = max(EVERNOTE.PAGING_TIMER_INTERVAL, 60*10) - suffix = "<BR>Delaying Auto Paging: Per EVERNOTE.PAGING_TIMER_INTERVAL, " + def proceed_autopage(self): + if not self.autoPagingEnabled: + return + global latestEDAMRateLimit, latestSocketError + col = self.anki.collection() + status = self.ImportProgress.Status + if status == EvernoteAPIStatus.RateLimitError: + m, s = divmod(latestEDAMRateLimit, 60) + show_report(" > Error: Delaying Auto Paging", + "Over the rate limit when getting Evernote notes<BR>Evernote requested we wait %d:%02d min" % ( + m, s), delay=5) + mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(True), False) + return False + if status == EvernoteAPIStatus.SocketError: + show_report(" > Error: Delaying Auto Paging:", + "%s when getting Evernote notes" % latestSocketError[ + 'friendly_error_msg'], + "We will try again in 30 seconds", delay=5) + mw.progress.timer(30000, lambda: self.proceed(True), False) + return False + if self.MetadataProgress.IsFinished: + self.currentPage = 1 + if self.forceAutoPage: + show_report(" > Terminating Auto Paging", + "All %d notes have been processed and forceAutoPage is True" % self.MetadataProgress.Total, + delay=5) + if self.auto_page_callback: + self.auto_page_callback() + return True + elif col.conf.get(EVERNOTE.IMPORT.PAGING.RESTART.ENABLED, True): + restart = max(EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL, 60*15) + restart_title = " > Restarting Auto Paging" + restart_msg = "All %d notes have been processed and EVERNOTE.IMPORT.PAGING.RESTART.ENABLED is TRUE<BR>" % \ + self.MetadataProgress.Total + suffix = "Per EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL, " + else: + show_report(" > Completed Auto Paging", + "All %d notes have been processed and EVERNOTE.IMPORT.PAGING.RESTART.ENABLED is FALSE" % + self.MetadataProgress.Total, delay=5) + return True + else: # Paging still in progress + self.currentPage = self.MetadataProgress.Page + 1 + restart_title = " > Continuing Auto Paging" + restart_msg = "Page %d completed<BR>%d notes remain<BR>%d of %d notes have been processed" % ( + self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.Completed, + self.MetadataProgress.Total) + restart = 0 + if self.forceAutoPage: + suffix = "<BR>Not delaying as the forceAutoPage flag is set" + elif self.ImportProgress.APICallCount < EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS: + suffix = "<BR>Not delaying as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS" % ( + self.ImportProgress.APICallCount, EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS) + else: + restart = max(EVERNOTE.IMPORT.PAGING.INTERVAL, 60*10) + suffix = "<BR>Delaying Auto Paging: Per EVERNOTE.IMPORT.PAGING.INTERVAL, " - if not self.forceAutoPage: - col.conf[SETTINGS.EVERNOTE_PAGINATION_CURRENT_PAGE] = self.currentPage - col.setMod() - col.save() + if not self.forceAutoPage: + col.conf[SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE] = self.currentPage + col.setMod() + col.save() - if restart > 0: - m, s = divmod(restart, 60) - suffix += "will delay for %d:%02d min before continuing\n" % (m, s) - show_report(restart_title, (restart_msg + suffix).split('<BR>'), delay=5) - if restart > 0: - mw.progress.timer(restart * 1000, lambda: self.proceed(True), False) - return False - return self.proceed(True) + if restart > 0: + m, s = divmod(restart, 60) + suffix += "will delay for %d:%02d min before continuing\n" % (m, s) + show_report(restart_title, (restart_msg + suffix).split('<BR>'), delay=5) + if restart > 0: + mw.progress.timer(restart * 1000, lambda: self.proceed(True), False) + return False + return self.proceed(True) - @property - def autoPagingEnabled(self): - return self.anki.collection().conf.get(SETTINGS.EVERNOTE_AUTO_PAGING, True) or self.forceAutoPage + @property + def autoPagingEnabled(self): + return self.anki.collection().conf.get(SETTINGS.EVERNOTE.AUTO_PAGING, True) or self.forceAutoPage diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index 35a8257..ca8283b 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -11,165 +11,164 @@ class EvernoteNoteFetcher(object): - def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): - """ + def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): + """ - :type evernote: ankEvernote.Evernote - """ - self.__reset_data__() - self.results = EvernoteNoteFetcherResults() - self.result = EvernoteNoteFetcherResult() - self.api_calls = 0 - self.keepEvernoteTags = True - self.deleteQueryTags = True - self.evernoteQueryTags = [] - self.tagsToDelete = [] - self.use_local_db_only = use_local_db_only - self.__update_sequence_number__ = -1 - if evernote: self.evernote = evernote - if not evernote_guid: - self.evernote_guid = "" - return - self.evernote_guid = evernote_guid - if evernote and not self.use_local_db_only: - self.__update_sequence_number__ = self.evernote.metadata[self.evernote_guid].updateSequenceNum - self.getNote() + :type evernote: ankEvernote.Evernote + """ + self.__reset_data__() + self.results = EvernoteNoteFetcherResults() + self.result = EvernoteNoteFetcherResult() + self.api_calls = 0 + self.keepEvernoteTags = True + self.deleteQueryTags = True + self.evernoteQueryTags = [] + self.tagsToDelete = [] + self.use_local_db_only = use_local_db_only + self.__update_sequence_number__ = -1 + if evernote: self.evernote = evernote + if not evernote_guid: + self.evernote_guid = "" + return + self.evernote_guid = evernote_guid + if evernote and not self.use_local_db_only: + self.__update_sequence_number__ = self.evernote.metadata[self.evernote_guid].updateSequenceNum + self.getNote() - def __reset_data__(self): - self.tagNames = [] - self.tagGuids = [] - self.whole_note = None - - def UpdateSequenceNum(self): - if self.result.Note: - return self.result.Note.UpdateSequenceNum - return self.__update_sequence_number__ + def __reset_data__(self): + self.tagNames = [] + self.tagGuids = [] + self.whole_note = None + + def UpdateSequenceNum(self): + if self.result.Note: + return self.result.Note.UpdateSequenceNum + return self.__update_sequence_number__ - def reportSuccess(self, note, source=None): - self.reportResult(EvernoteAPIStatus.Success, note, source) + def reportSuccess(self, note, source=None): + self.reportResult(EvernoteAPIStatus.Success, note, source) - def reportResult(self, status=None, note=None, source=None): - if note: - self.result.Note = note - status = EvernoteAPIStatus.Success - if not source: - source = 2 - if status: - self.result.Status = status - if source: - self.result.Source = source - self.results.reportResult(self.result) + def reportResult(self, status=None, note=None, source=None): + if note: + self.result.Note = note + status = EvernoteAPIStatus.Success + if not source: + source = 2 + if status: + self.result.Status = status + if source: + self.result.Source = source + self.results.reportResult(self.result) - def getNoteLocal(self): - # Check Anknotes database for note - query = "SELECT * FROM %s WHERE guid = '%s'" % ( - TABLES.EVERNOTE.NOTES, self.evernote_guid) - if self.UpdateSequenceNum() > -1: - query += " AND `updateSequenceNum` = %d" % self.UpdateSequenceNum() - db_note = ankDB().first(query) - """:type : sqlite.Row""" - if not db_note: return False - if not self.use_local_db_only: - log(' '*20 + "> getNoteLocal: GUID: '%s': %-40s" % (self.evernote_guid, db_note['title']), 'api') - assert db_note['guid'] == self.evernote_guid - self.reportSuccess(EvernoteNotePrototype(db_note=db_note), 1) - self.setNoteTags(tag_names=self.result.Note.TagNames) - return True + def getNoteLocal(self): + # Check Anknotes database for note + query = "SELECT * FROM %s WHERE guid = '%s'" % ( + TABLES.EVERNOTE.NOTES, self.evernote_guid) + if self.UpdateSequenceNum() > -1: + query += " AND `updateSequenceNum` = %d" % self.UpdateSequenceNum() + db_note = ankDB().first(query) + """:type : sqlite.Row""" + if not db_note: return False + if not self.use_local_db_only: + log(' '*20 + "> getNoteLocal: GUID: '%s': %-40s" % (self.evernote_guid, db_note['title']), 'api') + assert db_note['guid'] == self.evernote_guid + self.reportSuccess(EvernoteNotePrototype(db_note=db_note), 1) + self.setNoteTags(tag_names=self.result.Note.TagNames) + return True - def setNoteTags(self, tag_names=None, tag_guids=None): - if not self.keepEvernoteTags: - self.tagNames = [] - self.tagGuids = [] - return - if not tag_names: tag_names = self.tagNames if self.tagNames else self.result.Note.TagNames - if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else self.result.Note.TagGuids if self.result.Note else self.whole_note.tagGuids if self.whole_note else None - self.tagGuids, self.tagNames = self.evernote.get_matching_tag_data(tag_guids, tag_names) - - def addNoteFromServerToDB(self, whole_note=None, tag_names=None): - """ - Adds note to Anknote DB from an Evernote Note object provided by the Evernote API - :type whole_note : evernote.edam.type.ttypes.Note - """ - if whole_note: - self.whole_note = whole_note - if tag_names: - self.tagNames = tag_names - title = self.whole_note.title - log('Adding %s: %s' % (self.whole_note.guid, title), 'ankDB') - content = self.whole_note.content - tag_names = u',' + u','.join(self.tagNames).decode('utf-8') + u',' - if isinstance(title, str): - title = unicode(title, 'utf-8') - if isinstance(content, str): - content = unicode(content, 'utf-8') - if isinstance(tag_names, str): - tag_names = unicode(tag_names, 'utf-8') - title = title.replace(u'\'', u'\'\'') - content = content.replace(u'\'', u'\'\'') - tag_names = tag_names.replace(u'\'', u'\'\'') - if not self.tagGuids: - self.tagGuids = self.whole_note.tagGuids - sql_query_header = u'INSERT OR REPLACE INTO `%s`' % TABLES.EVERNOTE.NOTES - sql_query_header_history = u'INSERT INTO `%s`' % TABLES.EVERNOTE.NOTES_HISTORY - sql_query_columns = u'(`guid`,`title`,`content`,`updated`,`created`,`updateSequenceNum`,`notebookGuid`,`tagGuids`,`tagNames`) VALUES (\'%s\',\'%s\',\'%s\',%d,%d,%d,\'%s\',\'%s\',\'%s\');' % ( - self.whole_note.guid.decode('utf-8'), title, content, self.whole_note.updated, self.whole_note.created, - self.whole_note.updateSequenceNum, self.whole_note.notebookGuid.decode('utf-8'), - u',' + u','.join(self.tagGuids).decode('utf-8') + u',', tag_names) - sql_query = sql_query_header + sql_query_columns - log_sql('UPDATE_ANKI_DB: Add Note: SQL Query: ' + sql_query) - ankDB().execute(sql_query) - sql_query = sql_query_header_history + sql_query_columns - ankDB().execute(sql_query) - ankDB().commit() + def setNoteTags(self, tag_names=None, tag_guids=None): + if not self.keepEvernoteTags: + self.tagNames = [] + self.tagGuids = [] + return + if not tag_names: tag_names = self.tagNames if self.tagNames else self.result.Note.TagNames if self.result.Note else self.whole_note.tagNames if self.whole_note else None + if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else self.result.Note.TagGuids if self.result.Note else self.whole_note.tagGuids if self.whole_note else None + self.tagGuids, self.tagNames = self.evernote.get_matching_tag_data(tag_guids, tag_names) + + def addNoteFromServerToDB(self, whole_note=None, tag_names=None): + """ + Adds note to Anknote DB from an Evernote Note object provided by the Evernote API + :type whole_note : evernote.edam.type.ttypes.Note + """ + if whole_note: + self.whole_note = whole_note + if tag_names: + self.tagNames = tag_names + title = self.whole_note.title + log('Adding %s: %s' % (self.whole_note.guid, title), 'ankDB') + content = self.whole_note.content + tag_names = u',' + u','.join(self.tagNames).decode('utf-8') + u',' + if isinstance(title, str): + title = unicode(title, 'utf-8') + if isinstance(content, str): + content = unicode(content, 'utf-8') + if isinstance(tag_names, str): + tag_names = unicode(tag_names, 'utf-8') + title = title.replace(u'\'', u'\'\'') + content = content.replace(u'\'', u'\'\'') + tag_names = tag_names.replace(u'\'', u'\'\'') + if not self.tagGuids: + self.tagGuids = self.whole_note.tagGuids + sql_query_header = u'INSERT OR REPLACE INTO `%s`' % TABLES.EVERNOTE.NOTES + sql_query_header_history = u'INSERT INTO `%s`' % TABLES.EVERNOTE.NOTES_HISTORY + sql_query_columns = u'(`guid`,`title`,`content`,`updated`,`created`,`updateSequenceNum`,`notebookGuid`,`tagGuids`,`tagNames`) VALUES (\'%s\',\'%s\',\'%s\',%d,%d,%d,\'%s\',\'%s\',\'%s\');' % ( + self.whole_note.guid.decode('utf-8'), title, content, self.whole_note.updated, self.whole_note.created, + self.whole_note.updateSequenceNum, self.whole_note.notebookGuid.decode('utf-8'), + u',' + u','.join(self.tagGuids).decode('utf-8') + u',', tag_names) + sql_query = sql_query_header + sql_query_columns + log_sql('UPDATE_ANKI_DB: Add Note: SQL Query: ' + sql_query) + ankDB().execute(sql_query) + sql_query = sql_query_header_history + sql_query_columns + ankDB().execute(sql_query) + ankDB().commit() - def getNoteRemoteAPICall(self): - self.evernote.initialize_note_store() - api_action_str = u'trying to retrieve a note. We will save the notes downloaded thus far.' - log_api(" > getNote [%3d]" % (self.api_calls + 1), "GUID: '%s'" % self.evernote_guid) + def getNoteRemoteAPICall(self): + self.evernote.initialize_note_store() + api_action_str = u'trying to retrieve a note. We will save the notes downloaded thus far.' + self.api_calls += 1 + log_api(" > getNote [%3d]" % self.api_calls, "GUID: '%s'" % self.evernote_guid) + try: + self.whole_note = self.evernote.noteStore.getNote(self.evernote.token, self.evernote_guid, True, False, + False, False) + """:type : evernote.edam.type.ttypes.Note""" + except EDAMSystemException as e: + if HandleEDAMRateLimitError(e, api_action_str): + self.reportResult(EvernoteAPIStatus.RateLimitError) + if DEBUG_RAISE_API_ERRORS: raise + return False + raise + except socket.error, v: + if HandleSocketError(v, api_action_str): + self.reportResult(EvernoteAPIStatus.SocketError) + if DEBUG_RAISE_API_ERRORS: raise + return False + raise + assert self.whole_note.guid == self.evernote_guid + return True - try: - self.whole_note = self.evernote.noteStore.getNote(self.evernote.token, self.evernote_guid, True, False, - False, False) - """:type : evernote.edam.type.ttypes.Note""" - except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - self.reportResult(EvernoteAPIStatus.RateLimitError) - if DEBUG_RAISE_API_ERRORS: raise - return False - raise - except socket.error, v: - if HandleSocketError(v, api_action_str): - self.reportResult(EvernoteAPIStatus.SocketError) - if DEBUG_RAISE_API_ERRORS: raise - return False - raise - assert self.whole_note.guid == self.evernote_guid - return True + def getNoteRemote(self): + if self.api_calls > EVERNOTE.IMPORT.API_CALLS_LIMIT > -1: + log("Aborting Evernote.getNoteRemote: EVERNOTE.IMPORT.API_CALLS_LIMIT of %d has been reached" % EVERNOTE.IMPORT.API_CALLS_LIMIT) + return None + if not self.getNoteRemoteAPICall(): return False + # self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_evernote_guids(self.whole_note.tagGuids) + self.setNoteTags(tag_guids=self.whole_note.tagGuids) + self.addNoteFromServerToDB() + if not self.keepEvernoteTags: self.tagNames = [] + self.reportSuccess(EvernoteNotePrototype(whole_note=self.whole_note, tags=self.tagNames)) + return True - def getNoteRemote(self): - # if self.getNoteCount > EVERNOTE.GET_NOTE_LIMIT: - # log("Aborting Evernote.getNoteRemote: EVERNOTE.GET_NOTE_LIMIT of %d has been reached" % EVERNOTE.GET_NOTE_LIMIT) - # return None - if not self.getNoteRemoteAPICall(): return False - self.api_calls += 1 - # self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_evernote_guids(self.whole_note.tagGuids) - self.setNoteTags(tag_guids=self.whole_note.tagGuids) - self.addNoteFromServerToDB() - if not self.keepEvernoteTags: self.tagNames = [] - self.reportSuccess(EvernoteNotePrototype(whole_note=self.whole_note, tags=self.tagNames)) - return True + def setNote(self, whole_note): + self.whole_note = whole_note + self.addNoteFromServerToDB() - def setNote(self, whole_note): - self.whole_note = whole_note - self.addNoteFromServerToDB() - - def getNote(self, evernote_guid=None): - self.__reset_data__() - if evernote_guid: - self.result.Note = None - self.evernote_guid = evernote_guid - self.__update_sequence_number__ = self.evernote.metadata[ - self.evernote_guid].updateSequenceNum if not self.use_local_db_only else -1 - if self.getNoteLocal(): return True - if self.use_local_db_only: return False - return self.getNoteRemote() + def getNote(self, evernote_guid=None): + self.__reset_data__() + if evernote_guid: + self.result.Note = None + self.evernote_guid = evernote_guid + self.__update_sequence_number__ = self.evernote.metadata[ + self.evernote_guid].updateSequenceNum if not self.use_local_db_only else -1 + if self.getNoteLocal(): return True + if self.use_local_db_only: return False + return self.getNoteRemote() diff --git a/anknotes/EvernoteNotePrototype.py b/anknotes/EvernoteNotePrototype.py index cc0a67a..997224d 100644 --- a/anknotes/EvernoteNotePrototype.py +++ b/anknotes/EvernoteNotePrototype.py @@ -4,131 +4,132 @@ from anknotes.logging import log, log_blank, log_error class EvernoteNotePrototype: - ################## CLASS Note ################ - Title = None - """:type: EvernoteNoteTitle""" - Content = "" - Guid = "" - UpdateSequenceNum = -1 - """:type: int""" - TagNames = [] - TagGuids = [] - NotebookGuid = "" - Status = EvernoteAPIStatus.Uninitialized - """:type : EvernoteAPIStatus """ - Children = [] - - @property - def Tags(self): - return self.TagNames - - def process_tags(self): - if isinstance(self.TagNames, str) or isinstance(self.TagNames, unicode): - self.TagNames = self.TagNames[1:-1].split(',') - if isinstance(self.TagGuids, str) or isinstance(self.TagGuids, unicode): - self.TagGuids = self.TagGuids[1:-1].split(',') - - def __repr__(self): - return u"<EN Note: %s: '%s'>" % (self.Guid, self.Title) - - def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid=None, updateSequenceNum=None, - whole_note=None, db_note=None): - """ - - :type whole_note: evernote.edam.type.ttypes.Note - :type db_note: sqlite3.dbapi2.Row - """ - - self.Status = EvernoteAPIStatus.Uninitialized - self.TagNames = tags - if whole_note is not None: - self.Title = EvernoteNoteTitle(whole_note) - self.Content = whole_note.content - self.Guid = whole_note.guid - self.NotebookGuid = whole_note.notebookGuid - self.UpdateSequenceNum = whole_note.updateSequenceNum - self.Status = EvernoteAPIStatus.Success - return - if db_note is not None: - self.Title = EvernoteNoteTitle(db_note) - db_note_keys = db_note.keys() - for key in ['content', 'guid', 'notebookGuid', 'updateSequenceNum', 'tagNames', 'tagGuids']: - if not key in db_note_keys: - log_error("FATAL ERROR: Unable to find key %s in db note %s! \n%s" % (key, self.Title.FullTitle, db_note_keys)) - log("Values: \n\n" + str({k: db_note[k] for k in db_note_keys }), 'EvernoteNotePrototypeInit') - else: - setattr(self, upperFirst(key), db_note[key]) - if isinstance(self.TagNames, str): - self.TagNames = unicode(self.TagNames, 'utf-8') - if isinstance(self.Content, str): - self.Content = unicode(self.Content, 'utf-8') - self.process_tags() - self.Status = EvernoteAPIStatus.Success - return - self.Title = EvernoteNoteTitle(title) - self.Content = content - self.Guid = guid - self.NotebookGuid = notebookGuid - self.UpdateSequenceNum = updateSequenceNum - self.Status = EvernoteAPIStatus.Manual - - def generateURL(self): - return generate_evernote_url(self.Guid) - - def generateLink(self, value=None): - return generate_evernote_link(self.Guid, self.Title.Name, value) - - def generateLevelLink(self, value=None): - return generate_evernote_link_by_level(self.Guid, self.Title.Name, value) - - ### Shortcuts to EvernoteNoteTitle Properties; Autogenerated with regex /def +(\w+)\(\)\:/def \1\(\):\r\n\treturn self.Title.\1\r\n/ - @property - def Level(self): - return self.Title.Level - - @property - def Depth(self): - return self.Title.Depth - - @property - def FullTitle(self): - return self.Title.FullTitle - - @property - def Name(self): - return self.Title.Name - - @property - def Root(self): - return self.Title.Root - - @property - def Base(self): - return self.Title.Base - - @property - def Parent(self): - return self.Title.Parent - - @property - def TitleParts(self): - return self.Title.TitleParts - - @property - def IsChild(self): - return self.Title.IsChild - - @property - def IsRoot(self): - return self.Title.IsRoot - - def IsAboveLevel(self, level_check): - return self.Title.IsAboveLevel(level_check) - - def IsBelowLevel(self, level_check): - return self.Title.IsBelowLevel(level_check) - - def IsLevel(self, level_check): - return self.Title.IsLevel(level_check) - - ################## END CLASS Note ################ + ################## CLASS Note ################ + Title = None + """:type: EvernoteNoteTitle""" + Content = "" + Guid = "" + UpdateSequenceNum = -1 + """:type: int""" + TagNames = [] + TagGuids = [] + NotebookGuid = None + Status = EvernoteAPIStatus.Uninitialized + """:type : EvernoteAPIStatus """ + Children = [] + + @property + def Tags(self): + return self.TagNames + + def process_tags(self): + if isinstance(self.TagNames, str) or isinstance(self.TagNames, unicode): + self.TagNames = self.TagNames[1:-1].split(',') + if isinstance(self.TagGuids, str) or isinstance(self.TagGuids, unicode): + self.TagGuids = self.TagGuids[1:-1].split(',') + + def __repr__(self): + return u"<EN Note: %s: '%s'>" % (self.Guid, self.Title) + + def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid=None, updateSequenceNum=None, + whole_note=None, db_note=None): + """ + + :type whole_note: evernote.edam.type.ttypes.Note + :type db_note: sqlite3.dbapi2.Row + """ + + self.Status = EvernoteAPIStatus.Uninitialized + self.TagNames = tags + if whole_note is not None: + if self.TagNames is None: self.TagNames = whole_note.tagNames + self.Title = EvernoteNoteTitle(whole_note) + self.Content = whole_note.content + self.Guid = whole_note.guid + self.NotebookGuid = whole_note.notebookGuid + self.UpdateSequenceNum = whole_note.updateSequenceNum + self.Status = EvernoteAPIStatus.Success + return + if db_note is not None: + self.Title = EvernoteNoteTitle(db_note) + db_note_keys = db_note.keys() + for key in ['content', 'guid', 'notebookGuid', 'updateSequenceNum', 'tagNames', 'tagGuids']: + if not key in db_note_keys: + log_error("FATAL ERROR: Unable to find key %s in db note %s! \n%s" % (key, self.Title.FullTitle, db_note_keys)) + log("Values: \n\n" + str({k: db_note[k] for k in db_note_keys }), 'EvernoteNotePrototypeInit') + else: + setattr(self, upperFirst(key), db_note[key]) + if isinstance(self.TagNames, str): + self.TagNames = unicode(self.TagNames, 'utf-8') + if isinstance(self.Content, str): + self.Content = unicode(self.Content, 'utf-8') + self.process_tags() + self.Status = EvernoteAPIStatus.Success + return + self.Title = EvernoteNoteTitle(title) + self.Content = content + self.Guid = guid + self.NotebookGuid = notebookGuid + self.UpdateSequenceNum = updateSequenceNum + self.Status = EvernoteAPIStatus.Manual + + def generateURL(self): + return generate_evernote_url(self.Guid) + + def generateLink(self, value=None): + return generate_evernote_link(self.Guid, self.Title.Name, value) + + def generateLevelLink(self, value=None): + return generate_evernote_link_by_level(self.Guid, self.Title.Name, value) + + ### Shortcuts to EvernoteNoteTitle Properties; Autogenerated with regex /def +(\w+)\(\)\:/def \1\(\):\r\n\treturn self.Title.\1\r\n/ + @property + def Level(self): + return self.Title.Level + + @property + def Depth(self): + return self.Title.Depth + + @property + def FullTitle(self): + return self.Title.FullTitle + + @property + def Name(self): + return self.Title.Name + + @property + def Root(self): + return self.Title.Root + + @property + def Base(self): + return self.Title.Base + + @property + def Parent(self): + return self.Title.Parent + + @property + def TitleParts(self): + return self.Title.TitleParts + + @property + def IsChild(self): + return self.Title.IsChild + + @property + def IsRoot(self): + return self.Title.IsRoot + + def IsAboveLevel(self, level_check): + return self.Title.IsAboveLevel(level_check) + + def IsBelowLevel(self, level_check): + return self.Title.IsBelowLevel(level_check) + + def IsLevel(self, level_check): + return self.Title.IsLevel(level_check) + + ################## END CLASS Note ################ diff --git a/anknotes/EvernoteNoteTitle.py b/anknotes/EvernoteNoteTitle.py index 9c198fc..0c61061 100644 --- a/anknotes/EvernoteNoteTitle.py +++ b/anknotes/EvernoteNoteTitle.py @@ -5,225 +5,225 @@ def generateTOCTitle(title): - title = EvernoteNoteTitle.titleObjectToString(title).upper() - for chr in u'αβδφḃ': - title = title.replace(chr.upper(), chr) - return title + title = EvernoteNoteTitle.titleObjectToString(title).upper() + for chr in u'αβδφḃ': + title = title.replace(chr.upper(), chr) + return title class EvernoteNoteTitle: - level = 0 - __title__ = "" - """:type: str""" - __titleParts__ = None - """:type: list[str]""" - - # # Parent = None - # def __str__(self): - # return "%d: %s" % (self.Level(), self.Title) - - def __repr__(self): - return "<%s:%s>" % (self.__class__.__name__, self.FullTitle) - - @property - def TitleParts(self): - if not self.FullTitle: return [] - if not self.__titleParts__: self.__titleParts__ = generateTitleParts(self.FullTitle) - return self.__titleParts__ - - @property - def Level(self): - """ - :rtype: int - :return: Current Level with 1 being the Root Title - """ - if not self.level: self.level = len(self.TitleParts) - return self.level - - @property - def Depth(self): - return self.Level - 1 - - def Parts(self, level=-1): - return self.Slice(level) - - def Part(self, level=-1): - mySlice = self.Parts(level) - if not mySlice: return None - return mySlice.Root - - def BaseParts(self, level=None): - return self.Slice(1, level) - - def Parents(self, level=-1): - # noinspection PyTypeChecker - return self.Slice(None, level) - - def Names(self, level=-1): - return self.Parts(level) - - @property - def TOCTitle(self): - return generateTOCTitle(self.FullTitle) - - @property - def TOCName(self): - return generateTOCTitle(self.Name) - - @property - def TOCRootTitle(self): - return generateTOCTitle(self.Root) - - @property - def Name(self): - return self.Part() - - @property - def Root(self): - return self.Parents(1).FullTitle - - @property - def Base(self): - return self.BaseParts() - - def Slice(self, start=0, end=None): - # print "Slicing: <%s> %s ~ %d,%d" % (type(self.Title), self.Title, start, end) - oldParts = self.TitleParts - # print "Slicing: %s ~ %d,%d from parts %s" % (self.Title, start, end, str(oldParts)) - assert self.FullTitle and oldParts - if start is None and end is None: - print "Slicing: %s ~ %d,%d from parts %s" % (self.FullTitle, start, end, str(oldParts)) - assert start is not None or end is not None - newParts = oldParts[start:end] - if len(newParts) == 0: - log_error("Slice failed for %s-%s of %s" % (str(start), str(end), self.FullTitle)) - # return None - assert len(newParts) > 0 - newStr = ': '.join(newParts) - # print "Slice: Just created new title %s from %s" % (newStr , self.Title) - return EvernoteNoteTitle(newStr) - - @property - def Parent(self): - return self.Parents() - - def IsAboveLevel(self, level_check): - return self.Level > level_check - - def IsBelowLevel(self, level_check): - return self.Level < level_check - - def IsLevel(self, level_check): - return self.Level == level_check - - @property - def IsChild(self): - return self.IsAboveLevel(1) - - @property - def IsRoot(self): - return self.IsLevel(1) - - @staticmethod - def titleObjectToString(title, recursion=0): - """ - :param title: Title in string, unicode, dict, sqlite, TOCKey or NoteTitle formats. Note objects are also parseable - :type title: None | str | unicode | dict[str,str] | sqlite.Row | EvernoteNoteTitle - :return: string Title - :rtype: str - """ - # if recursion == 0: - # strr = str_safe(title) - # try: log(u'\n---------------------------------%s' % strr, 'tOTS', timestamp=False) - # except: log(u'\n---------------------------------%s' % '[UNABLE TO DISPLAY TITLE]', 'tOTS', timestamp=False) - # pass - - if title is None: - # log('NoneType', 'tOTS', timestamp=False) - return "" - if isinstance(title, str) or isinstance(title, unicode): - # log('str/unicode', 'tOTS', timestamp=False) - return title - if hasattr(title, 'FullTitle'): - # log('FullTitle', 'tOTS', timestamp=False) - # noinspection PyCallingNonCallable - title = title.FullTitle() if callable(title.FullTitle) else title.FullTitle - elif hasattr(title, 'Title'): - # log('Title', 'tOTS', timestamp=False) - title = title.Title() if callable(title.Title) else title.Title - elif hasattr(title, 'title'): - # log('title', 'tOTS', timestamp=False) - title = title.title() if callable(title.title) else title.title - else: - try: - if hasattr(title, 'keys'): - keys = title.keys() if callable(title.keys) else title.keys - if 'title' in keys: - # log('keys[title]', 'tOTS', timestamp=False) - title = title['title'] - elif 'Title' in keys: - # log('keys[Title]', 'tOTS', timestamp=False) - title = title['Title'] - elif len(keys) == 0: - # log('keys[empty dict?]', 'tOTS', timestamp=False) - raise - else: - log('keys[Unknown Attr]: %s' % str(keys), 'tOTS', timestamp=False) - return "" - elif 'title' in title: - # log('[title]', 'tOTS', timestamp=False) - title = title['title'] - elif 'Title' in title: - # log('[Title]', 'tOTS', timestamp=False) - title = title['Title'] - elif FIELDS.TITLE in title: - # log('[FIELDS.TITLE]', 'tOTS', timestamp=False) - title = title[FIELDS.TITLE] - else: - # log('Nothing Found', 'tOTS', timestamp=False) - # log(title) - # log(title.keys()) - return title - except: - log('except', 'tOTS', timestamp=False) - log(title, 'toTS', timestamp=False) - raise LookupError - recursion += 1 - # log(u'recursing %d: ' % recursion, 'tOTS', timestamp=False) - return EvernoteNoteTitle.titleObjectToString(title, recursion) - - @property - def FullTitle(self): - """:rtype: str""" - return self.__title__ - - @property - def HTML(self): - return self.__html__ - - def __init__(self, titleObj=None): - """:type titleObj: str | unicode | sqlite.Row | EvernoteNoteTitle | evernote.edam.type.ttypes.Note | EvernoteNotePrototype.EvernoteNotePrototype """ - self.__html__ = self.titleObjectToString(titleObj) - self.__title__ = strip_tags_and_new_lines(self.__html__) + level = 0 + __title__ = "" + """:type: str""" + __titleParts__ = None + """:type: list[str]""" + + # # Parent = None + # def __str__(self): + # return "%d: %s" % (self.Level(), self.Title) + + def __repr__(self): + return "<%s:%s>" % (self.__class__.__name__, self.FullTitle) + + @property + def TitleParts(self): + if not self.FullTitle: return [] + if not self.__titleParts__: self.__titleParts__ = generateTitleParts(self.FullTitle) + return self.__titleParts__ + + @property + def Level(self): + """ + :rtype: int + :return: Current Level with 1 being the Root Title + """ + if not self.level: self.level = len(self.TitleParts) + return self.level + + @property + def Depth(self): + return self.Level - 1 + + def Parts(self, level=-1): + return self.Slice(level) + + def Part(self, level=-1): + mySlice = self.Parts(level) + if not mySlice: return None + return mySlice.Root + + def BaseParts(self, level=None): + return self.Slice(1, level) + + def Parents(self, level=-1): + # noinspection PyTypeChecker + return self.Slice(None, level) + + def Names(self, level=-1): + return self.Parts(level) + + @property + def TOCTitle(self): + return generateTOCTitle(self.FullTitle) + + @property + def TOCName(self): + return generateTOCTitle(self.Name) + + @property + def TOCRootTitle(self): + return generateTOCTitle(self.Root) + + @property + def Name(self): + return self.Part() + + @property + def Root(self): + return self.Parents(1).FullTitle + + @property + def Base(self): + return self.BaseParts() + + def Slice(self, start=0, end=None): + # print "Slicing: <%s> %s ~ %d,%d" % (type(self.Title), self.Title, start, end) + oldParts = self.TitleParts + # print "Slicing: %s ~ %d,%d from parts %s" % (self.Title, start, end, str(oldParts)) + assert self.FullTitle and oldParts + if start is None and end is None: + print "Slicing: %s ~ %d,%d from parts %s" % (self.FullTitle, start, end, str(oldParts)) + assert start is not None or end is not None + newParts = oldParts[start:end] + if len(newParts) == 0: + log_error("Slice failed for %s-%s of %s" % (str(start), str(end), self.FullTitle)) + # return None + assert len(newParts) > 0 + newStr = ': '.join(newParts) + # print "Slice: Just created new title %s from %s" % (newStr , self.Title) + return EvernoteNoteTitle(newStr) + + @property + def Parent(self): + return self.Parents() + + def IsAboveLevel(self, level_check): + return self.Level > level_check + + def IsBelowLevel(self, level_check): + return self.Level < level_check + + def IsLevel(self, level_check): + return self.Level == level_check + + @property + def IsChild(self): + return self.IsAboveLevel(1) + + @property + def IsRoot(self): + return self.IsLevel(1) + + @staticmethod + def titleObjectToString(title, recursion=0): + """ + :param title: Title in string, unicode, dict, sqlite, TOCKey or NoteTitle formats. Note objects are also parseable + :type title: None | str | unicode | dict[str,str] | sqlite.Row | EvernoteNoteTitle + :return: string Title + :rtype: str + """ + # if recursion == 0: + # strr = str_safe(title) + # try: log(u'\n---------------------------------%s' % strr, 'tOTS', timestamp=False) + # except: log(u'\n---------------------------------%s' % '[UNABLE TO DISPLAY TITLE]', 'tOTS', timestamp=False) + # pass + + if title is None: + # log('NoneType', 'tOTS', timestamp=False) + return "" + if isinstance(title, str) or isinstance(title, unicode): + # log('str/unicode', 'tOTS', timestamp=False) + return title + if hasattr(title, 'FullTitle'): + # log('FullTitle', 'tOTS', timestamp=False) + # noinspection PyCallingNonCallable + title = title.FullTitle() if callable(title.FullTitle) else title.FullTitle + elif hasattr(title, 'Title'): + # log('Title', 'tOTS', timestamp=False) + title = title.Title() if callable(title.Title) else title.Title + elif hasattr(title, 'title'): + # log('title', 'tOTS', timestamp=False) + title = title.title() if callable(title.title) else title.title + else: + try: + if hasattr(title, 'keys'): + keys = title.keys() if callable(title.keys) else title.keys + if 'title' in keys: + # log('keys[title]', 'tOTS', timestamp=False) + title = title['title'] + elif 'Title' in keys: + # log('keys[Title]', 'tOTS', timestamp=False) + title = title['Title'] + elif len(keys) == 0: + # log('keys[empty dict?]', 'tOTS', timestamp=False) + raise + else: + log('keys[Unknown Attr]: %s' % str(keys), 'tOTS', timestamp=False) + return "" + elif 'title' in title: + # log('[title]', 'tOTS', timestamp=False) + title = title['title'] + elif 'Title' in title: + # log('[Title]', 'tOTS', timestamp=False) + title = title['Title'] + elif FIELDS.TITLE in title: + # log('[FIELDS.TITLE]', 'tOTS', timestamp=False) + title = title[FIELDS.TITLE] + else: + # log('Nothing Found', 'tOTS', timestamp=False) + # log(title) + # log(title.keys()) + return title + except: + log('except', 'tOTS', timestamp=False) + log(title, 'toTS', timestamp=False) + raise LookupError + recursion += 1 + # log(u'recursing %d: ' % recursion, 'tOTS', timestamp=False) + return EvernoteNoteTitle.titleObjectToString(title, recursion) + + @property + def FullTitle(self): + """:rtype: str""" + return self.__title__ + + @property + def HTML(self): + return self.__html__ + + def __init__(self, titleObj=None): + """:type titleObj: str | unicode | sqlite.Row | EvernoteNoteTitle | evernote.edam.type.ttypes.Note | EvernoteNotePrototype.EvernoteNotePrototype """ + self.__html__ = self.titleObjectToString(titleObj) + self.__title__ = strip_tags_and_new_lines(self.__html__) def generateTitleParts(title): - title = EvernoteNoteTitle.titleObjectToString(title) - try: - strTitle = re.sub(':+', ':', title) - except: - log('generateTitleParts Unable to re.sub') - log(type(title)) - raise - if strTitle[-1] == ':': strTitle = strTitle[:-1] - if strTitle[0] == ':': strTitle = strTitle[1:] - partsText = strTitle.split(':') - count = len(partsText) - for i in range(1, count + 1): - txt = partsText[i - 1] - try: - if txt[-1] == ' ': txt = txt[:-1] - if txt[0] == ' ': txt = txt[1:] - except: - print_safe(title + ' -- ' + '"' + txt + '"') - raise - partsText[i - 1] = txt - return partsText + title = EvernoteNoteTitle.titleObjectToString(title) + try: + strTitle = re.sub(':+', ':', title) + except: + log('generateTitleParts Unable to re.sub') + log(type(title)) + raise + if strTitle[-1] == ':': strTitle = strTitle[:-1] + if strTitle[0] == ':': strTitle = strTitle[1:] + partsText = strTitle.split(':') + count = len(partsText) + for i in range(1, count + 1): + txt = partsText[i - 1] + try: + if txt[-1] == ' ': txt = txt[:-1] + if txt[0] == ' ': txt = txt[1:] + except: + print_safe(title + ' -- ' + '"' + txt + '"') + raise + partsText[i - 1] = txt + return partsText diff --git a/anknotes/EvernoteNotes.py b/anknotes/EvernoteNotes.py index 64e42b5..67052e4 100644 --- a/anknotes/EvernoteNotes.py +++ b/anknotes/EvernoteNotes.py @@ -5,9 +5,9 @@ from anknotes.EvernoteNoteTitle import generateTOCTitle try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite ### Anknotes Imports from anknotes.shared import * @@ -18,409 +18,409 @@ class EvernoteNoteProcessingFlags: - delayProcessing = False - populateRootTitlesList = True - populateRootTitlesDict = True - populateExistingRootTitlesList = False - populateExistingRootTitlesDict = False - populateMissingRootTitlesList = False - populateMissingRootTitlesDict = False - populateChildRootTitles = False - ignoreAutoTOCAsRootTitle = False - ignoreOutlineAsRootTitle = False - - def __init__(self, flags=None): - if isinstance(flags, bool): - if not flags: self.set_default(False) - if flags: self.update(flags) - - def set_default(self, flag): - self.populateRootTitlesList = flag - self.populateRootTitlesDict = flag - - def update(self, flags): - for flag_name, flag_value in flags: - if hasattr(self, flag_name): - setattr(self, flag_name, flag_value) + delayProcessing = False + populateRootTitlesList = True + populateRootTitlesDict = True + populateExistingRootTitlesList = False + populateExistingRootTitlesDict = False + populateMissingRootTitlesList = False + populateMissingRootTitlesDict = False + populateChildRootTitles = False + ignoreAutoTOCAsRootTitle = False + ignoreOutlineAsRootTitle = False + + def __init__(self, flags=None): + if isinstance(flags, bool): + if not flags: self.set_default(False) + if flags: self.update(flags) + + def set_default(self, flag): + self.populateRootTitlesList = flag + self.populateRootTitlesDict = flag + + def update(self, flags): + for flag_name, flag_value in flags: + if hasattr(self, flag_name): + setattr(self, flag_name, flag_value) class EvernoteNotesCollection: - TitlesList = [] - TitlesDict = {} - NotesDict = {} - """:type : dict[str, EvernoteNote.EvernoteNote]""" - ChildNotesDict = {} - """:type : dict[str, EvernoteNote.EvernoteNote]""" - ChildTitlesDict = {} - - def __init__(self): - self.TitlesList = [] - self.TitlesDict = {} - self.NotesDict = {} - self.ChildNotesDict = {} - self.ChildTitlesDict = {} + TitlesList = [] + TitlesDict = {} + NotesDict = {} + """:type : dict[str, EvernoteNote.EvernoteNote]""" + ChildNotesDict = {} + """:type : dict[str, EvernoteNote.EvernoteNote]""" + ChildTitlesDict = {} + + def __init__(self): + self.TitlesList = [] + self.TitlesDict = {} + self.NotesDict = {} + self.ChildNotesDict = {} + self.ChildTitlesDict = {} class EvernoteNotes: - ################## CLASS Notes ################ - Notes = {} - """:type : dict[str, EvernoteNote.EvernoteNote]""" - RootNotes = EvernoteNotesCollection() - RootNotesChildren = EvernoteNotesCollection() - processingFlags = EvernoteNoteProcessingFlags() - baseQuery = "1" - - def __init__(self, delayProcessing=False): - self.processingFlags.delayProcessing = delayProcessing - self.RootNotes = EvernoteNotesCollection() - - def addNoteSilently(self, enNote): - """:type enNote: EvernoteNote.EvernoteNote""" - assert enNote - self.Notes[enNote.Guid] = enNote - - def addNote(self, enNote): - """:type enNote: EvernoteNote.EvernoteNote""" - assert enNote - self.addNoteSilently(enNote) - if self.processingFlags.delayProcessing: return - self.processNote(enNote) - - def addDBNote(self, dbNote): - """:type dbNote: sqlite.Row""" - enNote = EvernoteNotePrototype(db_note=dbNote) - if not enNote: - log(dbNote) - log(dbNote.keys) - log(dir(dbNote)) - assert enNote - self.addNote(enNote) - - def addDBNotes(self, dbNotes): - """:type dbNotes: list[sqlite.Row]""" - for dbNote in dbNotes: - self.addDBNote(dbNote) - - def addDbQuery(self, sql_query, order=''): - sql_query = "SELECT * FROM %s WHERE (%s) AND (%s) " % (TABLES.EVERNOTE.NOTES, self.baseQuery, sql_query) - if order: sql_query += ' ORDER BY ' + order - dbNotes = ankDB().execute(sql_query) - self.addDBNotes(dbNotes) - - @staticmethod - def getNoteFromDB(query): - """ - - :param query: - :return: - :rtype : sqlite.Row - """ - sql_query = "SELECT * FROM %s WHERE %s " % (TABLES.EVERNOTE.NOTES, query) - dbNote = ankDB().first(sql_query) - if not dbNote: return None - return dbNote - - def getNoteFromDBByGuid(self, guid): - sql_query = "guid = '%s' " % guid - return self.getNoteFromDB(sql_query) - - def getEnNoteFromDBByGuid(self, guid): - return EvernoteNotePrototype(db_note=self.getNoteFromDBByGuid(guid)) - - - # def addChildNoteHierarchically(self, enChildNotes, enChildNote): - # parts = enChildNote.Title.TitleParts - # dict_updated = {} - # dict_building = {parts[len(parts)-1]: enChildNote} - # print_safe(parts) - # for i in range(len(parts), 1, -1): - # dict_building = {parts[i - 1]: dict_building} - # log_dump(dict_building) - # enChildNotes.update(dict_building) - # log_dump(enChildNotes) - # return enChildNotes - - def processNote(self, enNote): - """:type enNote: EvernoteNote.EvernoteNote""" - if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict or self.processingFlags.populateMissingRootTitlesList or self.processingFlags.populateMissingRootTitlesDict: - if enNote.IsChild: - # log([enNote.Title, enNote.Level, enNote.Title.TitleParts, enNote.IsChild]) - rootTitle = enNote.Title.Root - rootTitleStr = generateTOCTitle(rootTitle) - if self.processingFlags.populateMissingRootTitlesList or self.processingFlags.populateMissingRootTitlesDict: - if not rootTitleStr in self.RootNotesExisting.TitlesList: - if not rootTitleStr in self.RootNotesMissing.TitlesList: - self.RootNotesMissing.TitlesList.append(rootTitleStr) - self.RootNotesMissing.ChildTitlesDict[rootTitleStr] = {} - self.RootNotesMissing.ChildNotesDict[rootTitleStr] = {} - if not enNote.Title.Base: - log(enNote.Title) - log(enNote.Base) - assert enNote.Title.Base - childBaseTitleStr = enNote.Title.Base.FullTitle - if childBaseTitleStr in self.RootNotesMissing.ChildTitlesDict[rootTitleStr]: - log_dump(self.RootNotesMissing.ChildTitlesDict[rootTitleStr], repr(enNote)) - assert not childBaseTitleStr in self.RootNotesMissing.ChildTitlesDict[rootTitleStr] - self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitleStr] = enNote.Guid - self.RootNotesMissing.ChildNotesDict[rootTitleStr][enNote.Guid] = enNote - if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: - if not rootTitleStr in self.RootNotes.TitlesList: - self.RootNotes.TitlesList.append(rootTitleStr) - if self.processingFlags.populateRootTitlesDict: - self.RootNotes.TitlesDict[rootTitleStr][enNote.Guid] = enNote.Title.Base - self.RootNotes.NotesDict[rootTitleStr][enNote.Guid] = enNote - if self.processingFlags.populateChildRootTitles or self.processingFlags.populateExistingRootTitlesList or self.processingFlags.populateExistingRootTitlesDict: - if enNote.IsRoot: - rootTitle = enNote.Title - rootTitleStr = generateTOCTitle(rootTitle) - rootGuid = enNote.Guid - if self.processingFlags.populateExistingRootTitlesList or self.processingFlags.populateExistingRootTitlesDict or self.processingFlags.populateMissingRootTitlesList: - if not rootTitleStr in self.RootNotesExisting.TitlesList: - self.RootNotesExisting.TitlesList.append(rootTitleStr) - if self.processingFlags.populateChildRootTitles: - childNotes = ankDB().execute("SELECT * FROM %s WHERE title LIKE '%s:%%' ORDER BY title ASC" % ( - TABLES.EVERNOTE.NOTES, rootTitleStr.replace("'", "''"))) - child_count = 0 - for childDbNote in childNotes: - child_count += 1 - childGuid = childDbNote['guid'] - childEnNote = EvernoteNotePrototype(db_note=childDbNote) - if child_count is 1: - self.RootNotesChildren.TitlesDict[rootGuid] = {} - self.RootNotesChildren.NotesDict[rootGuid] = {} - childBaseTitle = childEnNote.Title.Base - self.RootNotesChildren.TitlesDict[rootGuid][childGuid] = childBaseTitle - self.RootNotesChildren.NotesDict[rootGuid][childGuid] = childEnNote - - def processNotes(self, populateRootTitlesList=True, populateRootTitlesDict=True): - if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: - self.RootNotes = EvernoteNotesCollection() - - self.processingFlags.populateRootTitlesList = populateRootTitlesList - self.processingFlags.populateRootTitlesDict = populateRootTitlesDict - - for guid, enNote in self.Notes: - self.processNote(enNote) - - def processAllChildNotes(self): - self.processingFlags.populateRootTitlesList = True - self.processingFlags.populateRootTitlesDict = True - self.processNotes() - - def populateAllRootTitles(self): - self.getChildNotes() - self.processAllRootTitles() - - def processAllRootTitles(self): - count = 0 - for rootTitle, baseTitles in self.RootNotes.TitlesDict.items(): - count += 1 - baseNoteCount = len(baseTitles) - query = "UPPER(title) = '%s'" % escape_text_sql(rootTitle).upper() - if self.processingFlags.ignoreAutoTOCAsRootTitle: - query += " AND tagNames NOT LIKE '%%,%s,%%'" % EVERNOTE.TAG.AUTO_TOC - if self.processingFlags.ignoreOutlineAsRootTitle: - query += " AND tagNames NOT LIKE '%%,%s,%%'" % EVERNOTE.TAG.OUTLINE - rootNote = self.getNoteFromDB(query) - if rootNote: - self.RootNotesExisting.TitlesList.append(rootTitle) - else: - self.RootNotesMissing.TitlesList.append(rootTitle) - print_safe(rootNote, ' TOP LEVEL: [%4d::%2d]: [%7s] ' % (count, baseNoteCount, 'is_toc_outline_str')) - # for baseGuid, baseTitle in baseTitles: - # pass - - def getChildNotes(self): - self.addDbQuery("title LIKE '%%:%%'", 'title ASC') - - def getRootNotes(self): - query = "title NOT LIKE '%%:%%'" - if self.processingFlags.ignoreAutoTOCAsRootTitle: - query += " AND tagNames NOT LIKE '%%,%s,%%'" % EVERNOTE.TAG.AUTO_TOC - if self.processingFlags.ignoreOutlineAsRootTitle: - query += " AND tagNames NOT LIKE '%%,%s,%%'" % EVERNOTE.TAG.OUTLINE - self.addDbQuery(query, 'title ASC') - - def populateAllPotentialRootNotes(self): - self.RootNotesMissing = EvernoteNotesCollection() - processingFlags = EvernoteNoteProcessingFlags(False) - processingFlags.populateMissingRootTitlesList = True - processingFlags.populateMissingRootTitlesDict = True - self.processingFlags = processingFlags - - log(" CHECKING FOR ALL POTENTIAL ROOT TITLES ", 'RootTitles-TOC', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-TOC', timestamp=False) - log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) - self.getChildNotes() - log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-TOC', - timestamp=False) - self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) - - return self.processAllRootNotesMissing() - - def populateAllNonCustomRootNotes(self): - return self.populateAllRootNotesMissing(True, True) - - def populateAllRootNotesMissing(self, ignoreAutoTOCAsRootTitle=False, ignoreOutlineAsRootTitle=False): - processingFlags = EvernoteNoteProcessingFlags(False) - processingFlags.populateMissingRootTitlesList = True - processingFlags.populateMissingRootTitlesDict = True - processingFlags.populateExistingRootTitlesList = True - processingFlags.populateExistingRootTitlesDict = True - processingFlags.ignoreAutoTOCAsRootTitle = ignoreAutoTOCAsRootTitle - processingFlags.ignoreOutlineAsRootTitle = ignoreOutlineAsRootTitle - self.processingFlags = processingFlags - self.RootNotesExisting = EvernoteNotesCollection() - self.RootNotesMissing = EvernoteNotesCollection() - # log(', '.join(self.RootNotesMissing.TitlesList)) - self.getRootNotes() - - log(" CHECKING FOR MISSING ROOT TITLES ", 'RootTitles-Missing', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-Missing', timestamp=False) - log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) - log("Total %d Existing Root Titles" % len(self.RootNotesExisting.TitlesList), 'RootTitles-Missing', - timestamp=False) - self.getChildNotes() - log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-Missing', - timestamp=False) - self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) - - return self.processAllRootNotesMissing() - - def processAllRootNotesMissing(self): - """:rtype : list[EvernoteTOCEntry]""" - DEBUG_HTML = False - count = 0 - count_isolated = 0 - # log (" CREATING TOC's " , 'tocList', clear=True, timestamp=False) - # log ("------------------------------------------------" , 'tocList', timestamp=False) - # if DEBUG_HTML: log('<h1>CREATING TOCs</h1>', 'extra\\logs\\toc-ols\\toc-index.htm', timestamp=False, clear=True, extension='htm') - ols = [] - dbRows = [] - returns = [] - """:type : list[EvernoteTOCEntry]""" - ankDB().execute("DELETE FROM %s WHERE 1 " % TABLES.EVERNOTE.AUTO_TOC) - ankDB().commit() - # olsz = None - for rootTitleStr in self.RootNotesMissing.TitlesList: - count_child = 0 - childTitlesDictSortedKeys = sorted(self.RootNotesMissing.ChildTitlesDict[rootTitleStr], - key=lambda s: s.lower()) - total_child = len(childTitlesDictSortedKeys) - tags = [] - outline = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( - escape_text_sql(rootTitleStr.upper()), EVERNOTE.TAG.OUTLINE)) - currentAutoNote = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( - escape_text_sql(rootTitleStr.upper()), EVERNOTE.TAG.AUTO_TOC)) - notebookGuids = {} - childGuid = None - if total_child is 1 and not outline: - count_isolated += 1 - childBaseTitle = childTitlesDictSortedKeys[0] - childGuid = self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitle] - enChildNote = self.RootNotesMissing.ChildNotesDict[rootTitleStr][childGuid] - # tags = enChildNote.Tags - log(" > ISOLATED ROOT TITLE: [%-3d]: %-40s --> %-20s: %s %s" % ( - count_isolated, rootTitleStr + ':', childBaseTitle, childGuid, enChildNote), 'RootTitles-Isolated', - timestamp=False) - else: - count += 1 - log(" [%-3d] %s %s" % (count, rootTitleStr, '(O)' if outline else ' '), 'RootTitles-TOC', - timestamp=False) - # tocList = TOCList(rootTitleStr) - tocHierarchy = TOCHierarchyClass(rootTitleStr) - if outline: - tocHierarchy.Outline = TOCHierarchyClass(note=outline) - tocHierarchy.Outline.parent = tocHierarchy - - for childBaseTitle in childTitlesDictSortedKeys: - count_child += 1 - childGuid = self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitle] - enChildNote = self.RootNotesMissing.ChildNotesDict[rootTitleStr][childGuid] - if count_child == 1: - tags = enChildNote.Tags - else: - tags = [x for x in tags if x in enChildNote.Tags] - if not enChildNote.NotebookGuid in notebookGuids: - notebookGuids[enChildNote.NotebookGuid] = 0 - notebookGuids[enChildNote.NotebookGuid] += 1 - level = enChildNote.Title.Level - # childName = enChildNote.Title.Name - # childTitle = enChildNote.Title.FullTitle - log(" %2d: %d. --> %-60s" % (count_child, level, childBaseTitle), - 'RootTitles-TOC', timestamp=False) - # tocList.generateEntry(childTitle, enChildNote) - tocHierarchy.addNote(enChildNote) - realTitle = ankDB().scalar( - "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, childGuid)) - realTitle = realTitle[0:realTitle.index(':')] - # realTitleUTF8 = realTitle.encode('utf8') - notebookGuid = sorted(notebookGuids.items(), key=itemgetter(1), reverse=True)[0][0] - - real_root_title = generateTOCTitle(realTitle) - - ol = tocHierarchy.GetOrderedList() - tocEntry = EvernoteTOCEntry(real_root_title, ol, ',' + ','.join(tags) + ',', notebookGuid) - returns.append(tocEntry) - dbRows.append(tocEntry.items()) - # ol = realTitleUTF8 - # if olsz is None: olsz = ol - # olsz += ol - # ol = '<OL>\r\n%s</OL>\r\n' - - # strr = tocHierarchy.__str__() - if DEBUG_HTML: - ols.append(ol) - olutf8 = ol.encode('utf8') - fn = 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_') + '.htm' - full_path = os.path.join(ANKNOTES.FOLDER_LOGS, fn) - if not os.path.exists(os.path.dirname(full_path)): - os.mkdir(os.path.dirname(full_path)) - file_object = open(full_path, 'w') - file_object.write(olutf8) - file_object.close() - - # if DEBUG_HTML: log(ol, 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_'), timestamp=False, clear=True, extension='htm') - # log("Created TOC #%d:\n%s\n\n" % (count, strr), 'tocList', timestamp=False) - if DEBUG_HTML: - ols_html = u'\r\n<BR><BR><HR><BR><BR>\r\n'.join(ols) - fn = 'toc-ols\\toc-index.htm' - file_object = open(os.path.join(ANKNOTES.FOLDER_LOGS, fn), 'w') - try: file_object.write(u'<h1>CREATING TOCs</h1>\n\n' + ols_html) - except: - try: file_object.write(u'<h1>CREATING TOCs</h1>\n\n' + ols_html.encode('utf-8')) - except: pass - - file_object.close() - - ankDB().executemany( - "INSERT INTO %s (root_title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?)" % TABLES.EVERNOTE.AUTO_TOC, - dbRows) - ankDB().commit() - - return returns - - def populateAllRootNotesWithoutTOCOrOutlineDesignation(self): - processingFlags = EvernoteNoteProcessingFlags() - processingFlags.populateRootTitlesList = False - processingFlags.populateRootTitlesDict = False - processingFlags.populateChildRootTitles = True - self.processingFlags = processingFlags - self.getRootNotes() - self.processAllRootNotesWithoutTOCOrOutlineDesignation() - - def processAllRootNotesWithoutTOCOrOutlineDesignation(self): - count = 0 - for rootGuid, childBaseTitleDicts in self.RootNotesChildren.TitlesDict.items(): - rootEnNote = self.Notes[rootGuid] - if len(childBaseTitleDicts.items()) > 0: - is_toc = EVERNOTE.TAG.TOC in rootEnNote.Tags - is_outline = EVERNOTE.TAG.OUTLINE in rootEnNote.Tags - is_both = is_toc and is_outline - is_none = not is_toc and not is_outline - is_toc_outline_str = "BOTH ???" if is_both else "TOC" if is_toc else "OUTLINE" if is_outline else "N/A" - if is_none: - count += 1 - print_safe(rootEnNote, ' TOP LEVEL: [%3d] %-8s: ' % (count, is_toc_outline_str)) + ################## CLASS Notes ################ + Notes = {} + """:type : dict[str, EvernoteNote.EvernoteNote]""" + RootNotes = EvernoteNotesCollection() + RootNotesChildren = EvernoteNotesCollection() + processingFlags = EvernoteNoteProcessingFlags() + baseQuery = "1" + + def __init__(self, delayProcessing=False): + self.processingFlags.delayProcessing = delayProcessing + self.RootNotes = EvernoteNotesCollection() + + def addNoteSilently(self, enNote): + """:type enNote: EvernoteNote.EvernoteNote""" + assert enNote + self.Notes[enNote.Guid] = enNote + + def addNote(self, enNote): + """:type enNote: EvernoteNote.EvernoteNote""" + assert enNote + self.addNoteSilently(enNote) + if self.processingFlags.delayProcessing: return + self.processNote(enNote) + + def addDBNote(self, dbNote): + """:type dbNote: sqlite.Row""" + enNote = EvernoteNotePrototype(db_note=dbNote) + if not enNote: + log(dbNote) + log(dbNote.keys) + log(dir(dbNote)) + assert enNote + self.addNote(enNote) + + def addDBNotes(self, dbNotes): + """:type dbNotes: list[sqlite.Row]""" + for dbNote in dbNotes: + self.addDBNote(dbNote) + + def addDbQuery(self, sql_query, order=''): + sql_query = "SELECT * FROM %s WHERE (%s) AND (%s) " % (TABLES.EVERNOTE.NOTES, self.baseQuery, sql_query) + if order: sql_query += ' ORDER BY ' + order + dbNotes = ankDB().execute(sql_query) + self.addDBNotes(dbNotes) + + @staticmethod + def getNoteFromDB(query): + """ + + :param query: + :return: + :rtype : sqlite.Row + """ + sql_query = "SELECT * FROM %s WHERE %s " % (TABLES.EVERNOTE.NOTES, query) + dbNote = ankDB().first(sql_query) + if not dbNote: return None + return dbNote + + def getNoteFromDBByGuid(self, guid): + sql_query = "guid = '%s' " % guid + return self.getNoteFromDB(sql_query) + + def getEnNoteFromDBByGuid(self, guid): + return EvernoteNotePrototype(db_note=self.getNoteFromDBByGuid(guid)) + + + # def addChildNoteHierarchically(self, enChildNotes, enChildNote): + # parts = enChildNote.Title.TitleParts + # dict_updated = {} + # dict_building = {parts[len(parts)-1]: enChildNote} + # print_safe(parts) + # for i in range(len(parts), 1, -1): + # dict_building = {parts[i - 1]: dict_building} + # log_dump(dict_building) + # enChildNotes.update(dict_building) + # log_dump(enChildNotes) + # return enChildNotes + + def processNote(self, enNote): + """:type enNote: EvernoteNote.EvernoteNote""" + if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict or self.processingFlags.populateMissingRootTitlesList or self.processingFlags.populateMissingRootTitlesDict: + if enNote.IsChild: + # log([enNote.Title, enNote.Level, enNote.Title.TitleParts, enNote.IsChild]) + rootTitle = enNote.Title.Root + rootTitleStr = generateTOCTitle(rootTitle) + if self.processingFlags.populateMissingRootTitlesList or self.processingFlags.populateMissingRootTitlesDict: + if not rootTitleStr in self.RootNotesExisting.TitlesList: + if not rootTitleStr in self.RootNotesMissing.TitlesList: + self.RootNotesMissing.TitlesList.append(rootTitleStr) + self.RootNotesMissing.ChildTitlesDict[rootTitleStr] = {} + self.RootNotesMissing.ChildNotesDict[rootTitleStr] = {} + if not enNote.Title.Base: + log(enNote.Title) + log(enNote.Base) + assert enNote.Title.Base + childBaseTitleStr = enNote.Title.Base.FullTitle + if childBaseTitleStr in self.RootNotesMissing.ChildTitlesDict[rootTitleStr]: + log_dump(self.RootNotesMissing.ChildTitlesDict[rootTitleStr], repr(enNote)) + assert not childBaseTitleStr in self.RootNotesMissing.ChildTitlesDict[rootTitleStr] + self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitleStr] = enNote.Guid + self.RootNotesMissing.ChildNotesDict[rootTitleStr][enNote.Guid] = enNote + if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: + if not rootTitleStr in self.RootNotes.TitlesList: + self.RootNotes.TitlesList.append(rootTitleStr) + if self.processingFlags.populateRootTitlesDict: + self.RootNotes.TitlesDict[rootTitleStr][enNote.Guid] = enNote.Title.Base + self.RootNotes.NotesDict[rootTitleStr][enNote.Guid] = enNote + if self.processingFlags.populateChildRootTitles or self.processingFlags.populateExistingRootTitlesList or self.processingFlags.populateExistingRootTitlesDict: + if enNote.IsRoot: + rootTitle = enNote.Title + rootTitleStr = generateTOCTitle(rootTitle) + rootGuid = enNote.Guid + if self.processingFlags.populateExistingRootTitlesList or self.processingFlags.populateExistingRootTitlesDict or self.processingFlags.populateMissingRootTitlesList: + if not rootTitleStr in self.RootNotesExisting.TitlesList: + self.RootNotesExisting.TitlesList.append(rootTitleStr) + if self.processingFlags.populateChildRootTitles: + childNotes = ankDB().execute("SELECT * FROM %s WHERE title LIKE '%s:%%' ORDER BY title ASC" % ( + TABLES.EVERNOTE.NOTES, rootTitleStr.replace("'", "''"))) + child_count = 0 + for childDbNote in childNotes: + child_count += 1 + childGuid = childDbNote['guid'] + childEnNote = EvernoteNotePrototype(db_note=childDbNote) + if child_count is 1: + self.RootNotesChildren.TitlesDict[rootGuid] = {} + self.RootNotesChildren.NotesDict[rootGuid] = {} + childBaseTitle = childEnNote.Title.Base + self.RootNotesChildren.TitlesDict[rootGuid][childGuid] = childBaseTitle + self.RootNotesChildren.NotesDict[rootGuid][childGuid] = childEnNote + + def processNotes(self, populateRootTitlesList=True, populateRootTitlesDict=True): + if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: + self.RootNotes = EvernoteNotesCollection() + + self.processingFlags.populateRootTitlesList = populateRootTitlesList + self.processingFlags.populateRootTitlesDict = populateRootTitlesDict + + for guid, enNote in self.Notes: + self.processNote(enNote) + + def processAllChildNotes(self): + self.processingFlags.populateRootTitlesList = True + self.processingFlags.populateRootTitlesDict = True + self.processNotes() + + def populateAllRootTitles(self): + self.getChildNotes() + self.processAllRootTitles() + + def processAllRootTitles(self): + count = 0 + for rootTitle, baseTitles in self.RootNotes.TitlesDict.items(): + count += 1 + baseNoteCount = len(baseTitles) + query = "UPPER(title) = '%s'" % escape_text_sql(rootTitle).upper() + if self.processingFlags.ignoreAutoTOCAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.AUTO_TOC + if self.processingFlags.ignoreOutlineAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.OUTLINE + rootNote = self.getNoteFromDB(query) + if rootNote: + self.RootNotesExisting.TitlesList.append(rootTitle) + else: + self.RootNotesMissing.TitlesList.append(rootTitle) + print_safe(rootNote, ' TOP LEVEL: [%4d::%2d]: [%7s] ' % (count, baseNoteCount, 'is_toc_outline_str')) + # for baseGuid, baseTitle in baseTitles: + # pass + + def getChildNotes(self): + self.addDbQuery("title LIKE '%%:%%'", 'title ASC') + + def getRootNotes(self): + query = "title NOT LIKE '%%:%%'" + if self.processingFlags.ignoreAutoTOCAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.AUTO_TOC + if self.processingFlags.ignoreOutlineAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.OUTLINE + self.addDbQuery(query, 'title ASC') + + def populateAllPotentialRootNotes(self): + self.RootNotesMissing = EvernoteNotesCollection() + processingFlags = EvernoteNoteProcessingFlags(False) + processingFlags.populateMissingRootTitlesList = True + processingFlags.populateMissingRootTitlesDict = True + self.processingFlags = processingFlags + + log(" CHECKING FOR ALL POTENTIAL ROOT TITLES ", 'RootTitles-TOC', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-TOC', timestamp=False) + log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) + self.getChildNotes() + log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-TOC', + timestamp=False) + self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) + + return self.processAllRootNotesMissing() + + def populateAllNonCustomRootNotes(self): + return self.populateAllRootNotesMissing(True, True) + + def populateAllRootNotesMissing(self, ignoreAutoTOCAsRootTitle=False, ignoreOutlineAsRootTitle=False): + processingFlags = EvernoteNoteProcessingFlags(False) + processingFlags.populateMissingRootTitlesList = True + processingFlags.populateMissingRootTitlesDict = True + processingFlags.populateExistingRootTitlesList = True + processingFlags.populateExistingRootTitlesDict = True + processingFlags.ignoreAutoTOCAsRootTitle = ignoreAutoTOCAsRootTitle + processingFlags.ignoreOutlineAsRootTitle = ignoreOutlineAsRootTitle + self.processingFlags = processingFlags + self.RootNotesExisting = EvernoteNotesCollection() + self.RootNotesMissing = EvernoteNotesCollection() + # log(', '.join(self.RootNotesMissing.TitlesList)) + self.getRootNotes() + + log(" CHECKING FOR MISSING ROOT TITLES ", 'RootTitles-Missing', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-Missing', timestamp=False) + log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) + log("Total %d Existing Root Titles" % len(self.RootNotesExisting.TitlesList), 'RootTitles-Missing', + timestamp=False) + self.getChildNotes() + log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-Missing', + timestamp=False) + self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) + + return self.processAllRootNotesMissing() + + def processAllRootNotesMissing(self): + """:rtype : list[EvernoteTOCEntry]""" + DEBUG_HTML = False + count = 0 + count_isolated = 0 + # log (" CREATING TOC's " , 'tocList', clear=True, timestamp=False) + # log ("------------------------------------------------" , 'tocList', timestamp=False) + # if DEBUG_HTML: log('<h1>CREATING TOCs</h1>', 'extra\\logs\\toc-ols\\toc-index.htm', timestamp=False, clear=True, extension='htm') + ols = [] + dbRows = [] + returns = [] + """:type : list[EvernoteTOCEntry]""" + ankDB().execute("DELETE FROM %s WHERE 1 " % TABLES.AUTO_TOC) + ankDB().commit() + # olsz = None + for rootTitleStr in self.RootNotesMissing.TitlesList: + count_child = 0 + childTitlesDictSortedKeys = sorted(self.RootNotesMissing.ChildTitlesDict[rootTitleStr], + key=lambda s: s.lower()) + total_child = len(childTitlesDictSortedKeys) + tags = [] + outline = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( + escape_text_sql(rootTitleStr.upper()), TAGS.OUTLINE)) + currentAutoNote = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( + escape_text_sql(rootTitleStr.upper()), TAGS.AUTO_TOC)) + notebookGuids = {} + childGuid = None + if total_child is 1 and not outline: + count_isolated += 1 + childBaseTitle = childTitlesDictSortedKeys[0] + childGuid = self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitle] + enChildNote = self.RootNotesMissing.ChildNotesDict[rootTitleStr][childGuid] + # tags = enChildNote.Tags + log(" > ISOLATED ROOT TITLE: [%-3d]: %-40s --> %-20s: %s %s" % ( + count_isolated, rootTitleStr + ':', childBaseTitle, childGuid, enChildNote), 'RootTitles-Isolated', + timestamp=False) + else: + count += 1 + log(" [%-3d] %s %s" % (count, rootTitleStr, '(O)' if outline else ' '), 'RootTitles-TOC', + timestamp=False) + # tocList = TOCList(rootTitleStr) + tocHierarchy = TOCHierarchyClass(rootTitleStr) + if outline: + tocHierarchy.Outline = TOCHierarchyClass(note=outline) + tocHierarchy.Outline.parent = tocHierarchy + + for childBaseTitle in childTitlesDictSortedKeys: + count_child += 1 + childGuid = self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitle] + enChildNote = self.RootNotesMissing.ChildNotesDict[rootTitleStr][childGuid] + if count_child == 1: + tags = enChildNote.Tags + else: + tags = [x for x in tags if x in enChildNote.Tags] + if not enChildNote.NotebookGuid in notebookGuids: + notebookGuids[enChildNote.NotebookGuid] = 0 + notebookGuids[enChildNote.NotebookGuid] += 1 + level = enChildNote.Title.Level + # childName = enChildNote.Title.Name + # childTitle = enChildNote.Title.FullTitle + log(" %2d: %d. --> %-60s" % (count_child, level, childBaseTitle), + 'RootTitles-TOC', timestamp=False) + # tocList.generateEntry(childTitle, enChildNote) + tocHierarchy.addNote(enChildNote) + realTitle = ankDB().scalar( + "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, childGuid)) + realTitle = realTitle[0:realTitle.index(':')] + # realTitleUTF8 = realTitle.encode('utf8') + notebookGuid = sorted(notebookGuids.items(), key=itemgetter(1), reverse=True)[0][0] + + real_root_title = generateTOCTitle(realTitle) + + ol = tocHierarchy.GetOrderedList() + tocEntry = EvernoteTOCEntry(real_root_title, ol, ',' + ','.join(tags) + ',', notebookGuid) + returns.append(tocEntry) + dbRows.append(tocEntry.items()) + # ol = realTitleUTF8 + # if olsz is None: olsz = ol + # olsz += ol + # ol = '<OL>\r\n%s</OL>\r\n' + + # strr = tocHierarchy.__str__() + if DEBUG_HTML: + ols.append(ol) + olutf8 = ol.encode('utf8') + fn = 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_') + '.htm' + full_path = os.path.join(FOLDERS.LOGS, fn) + if not os.path.exists(os.path.dirname(full_path)): + os.mkdir(os.path.dirname(full_path)) + file_object = open(full_path, 'w') + file_object.write(olutf8) + file_object.close() + + # if DEBUG_HTML: log(ol, 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_'), timestamp=False, clear=True, extension='htm') + # log("Created TOC #%d:\n%s\n\n" % (count, strr), 'tocList', timestamp=False) + if DEBUG_HTML: + ols_html = u'\r\n<BR><BR><HR><BR><BR>\r\n'.join(ols) + fn = 'toc-ols\\toc-index.htm' + file_object = open(os.path.join(FOLDERS.LOGS, fn), 'w') + try: file_object.write(u'<h1>CREATING TOCs</h1>\n\n' + ols_html) + except: + try: file_object.write(u'<h1>CREATING TOCs</h1>\n\n' + ols_html.encode('utf-8')) + except: pass + + file_object.close() + + ankDB().executemany( + "INSERT INTO %s (root_title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?)" % TABLES.AUTO_TOC, + dbRows) + ankDB().commit() + + return returns + + def populateAllRootNotesWithoutTOCOrOutlineDesignation(self): + processingFlags = EvernoteNoteProcessingFlags() + processingFlags.populateRootTitlesList = False + processingFlags.populateRootTitlesDict = False + processingFlags.populateChildRootTitles = True + self.processingFlags = processingFlags + self.getRootNotes() + self.processAllRootNotesWithoutTOCOrOutlineDesignation() + + def processAllRootNotesWithoutTOCOrOutlineDesignation(self): + count = 0 + for rootGuid, childBaseTitleDicts in self.RootNotesChildren.TitlesDict.items(): + rootEnNote = self.Notes[rootGuid] + if len(childBaseTitleDicts.items()) > 0: + is_toc = TAGS.TOC in rootEnNote.Tags + is_outline = TAGS.OUTLINE in rootEnNote.Tags + is_both = is_toc and is_outline + is_none = not is_toc and not is_outline + is_toc_outline_str = "BOTH ???" if is_both else "TOC" if is_toc else "OUTLINE" if is_outline else "N/A" + if is_none: + count += 1 + print_safe(rootEnNote, ' TOP LEVEL: [%3d] %-8s: ' % (count, is_toc_outline_str)) diff --git a/anknotes/__main__.py b/anknotes/__main__.py index 0500b04..ce3d72f 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -2,9 +2,11 @@ ### Python Imports import os try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite + is_pysqlite = True except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite + is_pysqlite = False ### Anknotes Shared Imports from anknotes.shared import * @@ -15,100 +17,224 @@ ### Evernote Imports ### Anki Imports +from anki.find import Finder from anki.hooks import wrap, addHook from aqt.preferences import Preferences from aqt import mw, browser # from aqt.qt import QIcon, QTreeWidget, QTreeWidgetItem -from aqt.qt import Qt, QIcon, QTreeWidget, QTreeWidgetItem +from aqt.qt import Qt, QIcon, QTreeWidget, QTreeWidgetItem, QDesktopServices, QUrl +from aqt.webview import AnkiWebView # from aqt.qt.Qt import MatchFlag # from aqt.qt.qt import MatchFlag def import_timer_toggle(): - title = "&Enable Auto Import On Profile Load" - doAutoImport = mw.col.conf.get( - SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False) - if doAutoImport: - lastImport = mw.col.conf.get(SETTINGS.EVERNOTE_LAST_IMPORT, None) - importDelay = 0 - if lastImport: - td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) - minimum = timedelta(seconds=max(EVERNOTE.IMPORT_TIMER_INTERVAL, 20*60)) - if td < minimum: - importDelay = (minimum - td).total_seconds() * 1000 - if importDelay is 0: - menu.import_from_evernote() - else: - m, s = divmod(importDelay / 1000, 60) - log("> Starting Auto Import, Triggered by Profile Load, in %d:%02d min" % (m, s)) - mw.progress.timer(importDelay, menu.import_from_evernote, False) + title = "&Enable Auto Import On Profile Load" + doAutoImport = mw.col.conf.get( + SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False) + if doAutoImport: + lastImport = mw.col.conf.get(SETTINGS.EVERNOTE.LAST_IMPORT, None) + importDelay = 0 + if lastImport: + td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) + minimum = timedelta(seconds=max(EVERNOTE.IMPORT.INTERVAL, 20*60)) + if td < minimum: + importDelay = (minimum - td).total_seconds() * 1000 + if importDelay is 0: + menu.import_from_evernote() + else: + m, s = divmod(importDelay / 1000, 60) + log("> Starting Auto Import, Triggered by Profile Load, in %d:%02d min" % (m, s)) + mw.progress.timer(importDelay, menu.import_from_evernote, False) def _findEdited((val, args)): - try: days = int(val) - except ValueError: return - return "c.mod > %d" % (time.time() - days * 86400) - + try: days = int(val) + except ValueError: return + return "c.mod > %d" % (time.time() - days * 86400) + +def _findHierarchy((val, args)): + if val == 'root': + return "n.sfld NOT LIKE '%:%' AND ank.title LIKE '%' || n.sfld || ':%'" + if val == 'sub': + return 'n.sfld like "%:%"' + if val == 'child': + return "UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) IN (SELECT UPPER(title) FROM %s WHERE title NOT LIKE '%%:%%' AND tagNames LIKE '%%,%s,%%') " % (TABLES.EVERNOTE.NOTES, TAGS.TOC) + if val == 'orphan': + return "n.sfld LIKE '%%:%%' AND UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) NOT IN (SELECT UPPER(title) FROM %s WHERE title NOT LIKE '%%:%%' AND tagNames LIKE '%%,%s,%%') " % (TABLES.EVERNOTE.NOTES, TAGS.TOC) + # showInfo(val) + class CallbackItem(QTreeWidgetItem): - def __init__(self, root, name, onclick, oncollapse=None): - QTreeWidgetItem.__init__(self, root, [name]) - self.onclick = onclick - self.oncollapse = oncollapse + def __init__(self, root, name, onclick, oncollapse=None): + QTreeWidgetItem.__init__(self, root, [name]) + self.onclick = onclick + self.oncollapse = oncollapse def anknotes_browser_tagtree_wrap(self, root, _old): - """ - - :param root: - :type root : QTreeWidget - :param _old: - :return: - """ - tags = [(_("Edited This Week"), "view-pim-calendar.png", "edited:7")] - for name, icon, cmd in tags: - onclick = lambda c=cmd: self.setFilter(c) - widgetItem = QTreeWidgetItem([name]) - widgetItem.onclick = onclick - widgetItem.setIcon(0, QIcon(":/icons/" + icon)) - root = _old(self, root) - indices = root.findItems(_("Added Today"), Qt.MatchFixedString) - index = (root.indexOfTopLevelItem(indices[0]) + 1) if indices else 3 - root.insertTopLevelItem(index, widgetItem) - return root - + """ + + :param root: + :type root : QTreeWidget + :param _old: + :return: + """ + tags = [ + (_("Edited This Week"), "view-pim-calendar.png", "edited:7"), + (_("Root Notes"), "hierarchy:root"), + (_("Sub Notes"), "hierarchy:sub"), + (_("Child Notes"), "hierarchy:child"), + (_("Orphan Notes"), "hierarchy:orphan") + ] + # tags.reverse() + root = _old(self, root) + indices = root.findItems(_("Added Today"), Qt.MatchFixedString) + index = (root.indexOfTopLevelItem(indices[0]) + 1) if indices else 3 + from anknotes.graphics import icoEvernoteWeb + for name, icon, cmd in tags[:1]: + onclick = lambda c=cmd: self.setFilter(c) + widgetItem = QTreeWidgetItem([name]) + widgetItem.onclick = onclick + widgetItem.setIcon(0, QIcon(":/icons/" + icon)) + root.insertTopLevelItem(index, widgetItem) + root = self.CallbackItem(root, _("Anknotes Hierarchy"), None) + root.setExpanded(True) + root.setIcon(0, icoEvernoteWeb) + for name, cmd in tags[1:]: + item = self.CallbackItem(root, name,lambda c=cmd: self.setFilter(c)) + item.setIcon(0, icoEvernoteWeb) + return root + +def _findField(self, field, val, _old=None): + def doCheck(self, field, val): + field = field.lower() + val = val.replace("*", "%") + # find models that have that field + mods = {} + for m in self.col.models.all(): + for f in m['flds']: + if f['name'].lower() == field: + mods[str(m['id'])] = (m, f['ord']) + + if not mods: + # nothing has that field + return + # gather nids + + regex = re.escape(val).replace("\\_", ".").replace("\\%", ".*") + sql = """ +select id, mid, flds from notes +where mid in %s and flds like ? escape '\\'""" % ( + ids2str(mods.keys())) + nids = [] + for (id,mid,flds) in self.col.db.execute(sql, "%"+val+"%"): + flds = splitFields(flds) + ord = mods[str(mid)][1] + strg = flds[ord] + try: + if re.search("(?si)^"+regex+"$", strg): + nids.append(id) + except sre_constants.error: + return + if not nids: + return "0" + return "n.id in %s" % ids2str(nids) + + # val = doCheck(field, val) + + from anki.utils import ids2str, splitFields + import re, sre_constants + vtest = doCheck(self, field, val) + log("FindField for %s: %s: Total %d matches " %(field, str(val), len(vtest.split(','))), 'sql-finder') + return vtest + # return _old(self, field, val) + +def anknotes_finder_findCards_wrap(self, query, order=False, _old=None): + log("Searching with text " + query , 'sql-finder') + "Return a list of card ids for QUERY." + tokens = self._tokenize(query) + preds, args = self._where(tokens) + log("Tokens: %-20s Preds: %-20s Args: %-20s " % (str(tokens), str(preds), str(args)) , 'sql-finder') + if preds is None: + return [] + order, rev = self._order(order) + sql = self._query(preds, order) + # showInfo(sql) + try: + res = self.col.db.list(sql, *args) + except Exception as ex: + # invalid grouping + log("Error with query %s: %s.\n%s" % (query, str(ex), [sql, args]) , 'sql-finder') + return [] + if rev: + res.reverse() + return res + return _old(self, query, order) + +def anknotes_finder_query_wrap(self, preds=None, order=None, _old=None): + if _old is None or not isinstance(self, Finder): + log_dump([self, preds, order], 'Finder Query Wrap Error', 'finder') + return + sql = _old(self, preds, order) + if "ank." in preds: + sql = sql.replace("select c.id", "select distinct c.id").replace("from cards c", "from cards c, %s ank" % TABLES.EVERNOTE.NOTES) + log('Custom anknotes finder SELECT query: \n%s' % sql, 'sql-finder') + elif TABLES.EVERNOTE.NOTES in preds: + log('Custom anknotes finder alternate query: \n%s' % sql, 'sql-finder') + else: + log("Anki finder query: %s" % sql, 'sql-finder') + return sql + def anknotes_search_hook(search): - if not 'edited' in search: - search['edited'] = _findEdited - + if not 'edited' in search: + search['edited'] = _findEdited + if not 'hierarchy' in search: + search['hierarchy'] = _findHierarchy + +def reset_everything(): + ankDB().InitSeeAlso(True) + menu.resync_with_local_db() + menu.see_also([1, 2, 5, 6, 7]) + def anknotes_profile_loaded(): - if not os.path.exists(os.path.dirname(ANKNOTES.LAST_PROFILE_LOCATION)): os.makedirs(os.path.dirname(ANKNOTES.LAST_PROFILE_LOCATION)) - with open(ANKNOTES.LAST_PROFILE_LOCATION, 'w+') as myFile: - print>> myFile, mw.pm.name - menu.anknotes_load_menu_settings() - if ANKNOTES.ENABLE_VALIDATION and ANKNOTES.AUTOMATE_VALIDATION: - menu.upload_validated_notes(True) - import_timer_toggle() - if ANKNOTES.DEVELOPER_MODE_AUTOMATE: - ''' - For testing purposes only! - Add a function here and it will automatically run on profile load - You must create the files 'anknotes.developer' and 'anknotes.developer.automate' in the /extra/dev/ folder - ''' - menu.resync_with_local_db() - # menu.see_also() - # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) - # menu.see_also(3) - # menu.see_also(4) - # mw.progress.timer(20000, lambda : menu.find_deleted_notes(True), False) - # menu.see_also([3,4]) - # menu.resync_with_local_db() - pass - - + if not os.path.exists(os.path.dirname(FILES.USER.LAST_PROFILE_LOCATION)): os.makedirs(os.path.dirname(FILES.USER.LAST_PROFILE_LOCATION)) + with open(FILES.USER.LAST_PROFILE_LOCATION, 'w+') as myFile: + print>> myFile, mw.pm.name + menu.anknotes_load_menu_settings() + if EVERNOTE.UPLOAD.VALIDATION.ENABLED and EVERNOTE.UPLOAD.VALIDATION.AUTOMATED: + menu.upload_validated_notes(True) + import_timer_toggle() + + if ANKNOTES.DEVELOPER_MODE_AUTOMATE: + ''' + For testing purposes only! + Add a function here and it will automatically run on profile load + You must create the files 'anknotes.developer' and 'anknotes.developer.automate' in the /extra/dev/ folder + ''' + # reset_everything() + menu.see_also([7]) + + # menu.resync_with_local_db() + # menu.see_also([1, 2, 5, 6, 7]) + # menu.see_also([6, 7]) + # menu.resync_with_local_db() + # menu.see_also() + # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) + # menu.see_also(3) + # menu.see_also(4) + # mw.progress.timer(20000, lambda : menu.find_deleted_notes(True), False) + # menu.see_also([3,4]) + # menu.resync_with_local_db() + pass + def anknotes_onload(): - addHook("profileLoaded", anknotes_profile_loaded) - addHook("search", anknotes_search_hook) - browser.Browser._systemTagTree = wrap(browser.Browser._systemTagTree, anknotes_browser_tagtree_wrap, "around") - menu.anknotes_setup_menu() - Preferences.setupOptions = wrap(Preferences.setupOptions, settings.setup_evernote) + + addHook("profileLoaded", anknotes_profile_loaded) + addHook("search", anknotes_search_hook) + Finder._query = wrap(Finder._query, anknotes_finder_query_wrap, "around") + Finder._findField = wrap(Finder._findField, _findField, "around" ) + Finder.findCards = wrap(Finder.findCards, anknotes_finder_findCards_wrap, "around") + browser.Browser._systemTagTree = wrap(browser.Browser._systemTagTree, anknotes_browser_tagtree_wrap, "around") + menu.anknotes_setup_menu() + Preferences.setupOptions = wrap(Preferences.setupOptions, settings.setup_evernote) anknotes_onload() diff --git a/anknotes/_re.py b/anknotes/_re.py index 151ddc9..a7147bf 100644 --- a/anknotes/_re.py +++ b/anknotes/_re.py @@ -2,276 +2,276 @@ def compile(pattern, flags=0): - """Compile a regular expression pattern, returning a pattern object. + """Compile a regular expression pattern, returning a pattern object. - :type pattern: bytes | unicode - :type flags: int - :rtype: __Regex - """ - pass + :type pattern: bytes | unicode + :type flags: int + :rtype: __Regex + """ + pass def search(pattern, string, flags=0): - """Scan through string looking for a match, and return a corresponding - match instance. Return None if no position in the string matches. + """Scan through string looking for a match, and return a corresponding + match instance. Return None if no position in the string matches. - :type pattern: bytes | unicode | __Regex - :type string: T <= bytes | unicode - :type flags: int - :rtype: __Match[T] | None - """ - pass + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: __Match[T] | None + """ + pass def match(pattern, string, flags=0): - """Matches zero or more characters at the beginning of the string. + """Matches zero or more characters at the beginning of the string. - :type pattern: bytes | unicode | __Regex - :type string: T <= bytes | unicode - :type flags: int - :rtype: __Match[T] | None - """ - pass + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: __Match[T] | None + """ + pass def split(pattern, string, maxsplit=0, flags=0): - """Split string by the occurrences of pattern. + """Split string by the occurrences of pattern. - :type pattern: bytes | unicode | __Regex - :type string: T <= bytes | unicode - :type maxsplit: int - :type flags: int - :rtype: list[T] - """ - pass + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type maxsplit: int + :type flags: int + :rtype: list[T] + """ + pass def findall(pattern, string, flags=0): - """Return a list of all non-overlapping matches of pattern in string. + """Return a list of all non-overlapping matches of pattern in string. - :type pattern: bytes | unicode | __Regex - :type string: T <= bytes | unicode - :type flags: int - :rtype: list[T] - """ - pass + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: list[T] + """ + pass def finditer(pattern, string, flags=0): - """Return an iterator over all non-overlapping matches for the pattern in - string. For each match, the iterator returns a match object. + """Return an iterator over all non-overlapping matches for the pattern in + string. For each match, the iterator returns a match object. - :type pattern: bytes | unicode | __Regex - :type string: T <= bytes | unicode - :type flags: int - :rtype: collections.Iterable[__Match[T]] - """ - pass + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: collections.Iterable[__Match[T]] + """ + pass def sub(pattern, repl, string, count=0, flags=0): - """Return the string obtained by replacing the leftmost non-overlapping - occurrences of pattern in string by the replacement repl. + """Return the string obtained by replacing the leftmost non-overlapping + occurrences of pattern in string by the replacement repl. - :type pattern: bytes | unicode | __Regex - :type repl: bytes | unicode | collections.Callable - :type string: T <= bytes | unicode - :type count: int - :type flags: int - :rtype: T - """ - pass + :type pattern: bytes | unicode | __Regex + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :type flags: int + :rtype: T + """ + pass def subn(pattern, repl, string, count=0, flags=0): - """Return the tuple (new_string, number_of_subs_made) found by replacing - the leftmost non-overlapping occurrences of pattern with the - replacement repl. + """Return the tuple (new_string, number_of_subs_made) found by replacing + the leftmost non-overlapping occurrences of pattern with the + replacement repl. - :type pattern: bytes | unicode | __Regex - :type repl: bytes | unicode | collections.Callable - :type string: T <= bytes | unicode - :type count: int - :type flags: int - :rtype: (T, int) - """ - pass + :type pattern: bytes | unicode | __Regex + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :type flags: int + :rtype: (T, int) + """ + pass def escape(string): - """Escape all the characters in pattern except ASCII letters and numbers. + """Escape all the characters in pattern except ASCII letters and numbers. - :type string: T <= bytes | unicode - :type: T - """ - pass + :type string: T <= bytes | unicode + :type: T + """ + pass class __Regex(object): - """Mock class for a regular expression pattern object.""" - - def __init__(self, flags, groups, groupindex, pattern): - """Create a new pattern object. - - :type flags: int - :type groups: int - :type groupindex: dict[bytes | unicode, int] - :type pattern: bytes | unicode - """ - self.flags = flags - self.groups = groups - self.groupindex = groupindex - self.pattern = pattern - - def search(self, string, pos=0, endpos=-1): - """Scan through string looking for a match, and return a corresponding - match instance. Return None if no position in the string matches. - - :type string: T <= bytes | unicode - :type pos: int - :type endpos: int - :rtype: __Match[T] | None - """ - pass - - def match(self, string, pos=0, endpos=-1): - """Matches zero | more characters at the beginning of the string. - - :type string: T <= bytes | unicode - :type pos: int - :type endpos: int - :rtype: __Match[T] | None - """ - pass - - def split(self, string, maxsplit=0): - """Split string by the occurrences of pattern. - - :type string: T <= bytes | unicode - :type maxsplit: int - :rtype: list[T] - """ - pass - - def findall(self, string, pos=0, endpos=-1): - """Return a list of all non-overlapping matches of pattern in string. - - :type string: T <= bytes | unicode - :type pos: int - :type endpos: int - :rtype: list[T] - """ - pass - - def finditer(self, string, pos=0, endpos=-1): - """Return an iterator over all non-overlapping matches for the - pattern in string. For each match, the iterator returns a - match object. - - :type string: T <= bytes | unicode - :type pos: int - :type endpos: int - :rtype: collections.Iterable[__Match[T]] - """ - pass - - def sub(self, repl, string, count=0): - """Return the string obtained by replacing the leftmost non-overlapping - occurrences of pattern in string by the replacement repl. - - :type repl: bytes | unicode | collections.Callable - :type string: T <= bytes | unicode - :type count: int - :rtype: T - """ - pass - - def subn(self, repl, string, count=0): - """Return the tuple (new_string, number_of_subs_made) found by replacing - the leftmost non-overlapping occurrences of pattern with the - replacement repl. - - :type repl: bytes | unicode | collections.Callable - :type string: T <= bytes | unicode - :type count: int - :rtype: (T, int) - """ - pass + """Mock class for a regular expression pattern object.""" + + def __init__(self, flags, groups, groupindex, pattern): + """Create a new pattern object. + + :type flags: int + :type groups: int + :type groupindex: dict[bytes | unicode, int] + :type pattern: bytes | unicode + """ + self.flags = flags + self.groups = groups + self.groupindex = groupindex + self.pattern = pattern + + def search(self, string, pos=0, endpos=-1): + """Scan through string looking for a match, and return a corresponding + match instance. Return None if no position in the string matches. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: __Match[T] | None + """ + pass + + def match(self, string, pos=0, endpos=-1): + """Matches zero | more characters at the beginning of the string. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: __Match[T] | None + """ + pass + + def split(self, string, maxsplit=0): + """Split string by the occurrences of pattern. + + :type string: T <= bytes | unicode + :type maxsplit: int + :rtype: list[T] + """ + pass + + def findall(self, string, pos=0, endpos=-1): + """Return a list of all non-overlapping matches of pattern in string. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: list[T] + """ + pass + + def finditer(self, string, pos=0, endpos=-1): + """Return an iterator over all non-overlapping matches for the + pattern in string. For each match, the iterator returns a + match object. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: collections.Iterable[__Match[T]] + """ + pass + + def sub(self, repl, string, count=0): + """Return the string obtained by replacing the leftmost non-overlapping + occurrences of pattern in string by the replacement repl. + + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :rtype: T + """ + pass + + def subn(self, repl, string, count=0): + """Return the tuple (new_string, number_of_subs_made) found by replacing + the leftmost non-overlapping occurrences of pattern with the + replacement repl. + + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :rtype: (T, int) + """ + pass class __Match(object): - """Mock class for a match object.""" - - def __init__(self, pos, endpos, lastindex, lastgroup, re, string): - """Create a new match object. - - :type pos: int - :type endpos: int - :type lastindex: int | None - :type lastgroup: int | bytes | unicode | None - :type re: __Regex - :type string: bytes | unicode - :rtype: __Match[T] - """ - self.pos = pos - self.endpos = endpos - self.lastindex = lastindex - self.lastgroup = lastgroup - self.re = re - self.string = string - - def expand(self, template): - """Return the string obtained by doing backslash substitution on the - template string template. - - :type template: T - :rtype: T - """ - pass - - def group(self, *args): - """Return one or more subgroups of the match. - - :rtype: T | tuple - """ - pass - - def groups(self, default=None): - """Return a tuple containing all the subgroups of the match, from 1 up - to however many groups are in the pattern. - - :rtype: tuple - """ - pass - - def groupdict(self, default=None): - """Return a dictionary containing all the named subgroups of the match, - keyed by the subgroup name. - - :rtype: dict[bytes | unicode, T] - """ - pass - - def start(self, group=0): - """Return the index of the start of the substring matched by group. - - :type group: int | bytes | unicode - :rtype: int - """ - pass - - def end(self, group=0): - """Return the index of the end of the substring matched by group. - - :type group: int | bytes | unicode - :rtype: int - """ - pass - - def span(self, group=0): - """Return a 2-tuple (start, end) for the substring matched by group. - - :type group: int | bytes | unicode - :rtype: (int, int) - """ - pass + """Mock class for a match object.""" + + def __init__(self, pos, endpos, lastindex, lastgroup, re, string): + """Create a new match object. + + :type pos: int + :type endpos: int + :type lastindex: int | None + :type lastgroup: int | bytes | unicode | None + :type re: __Regex + :type string: bytes | unicode + :rtype: __Match[T] + """ + self.pos = pos + self.endpos = endpos + self.lastindex = lastindex + self.lastgroup = lastgroup + self.re = re + self.string = string + + def expand(self, template): + """Return the string obtained by doing backslash substitution on the + template string template. + + :type template: T + :rtype: T + """ + pass + + def group(self, *args): + """Return one or more subgroups of the match. + + :rtype: T | tuple + """ + pass + + def groups(self, default=None): + """Return a tuple containing all the subgroups of the match, from 1 up + to however many groups are in the pattern. + + :rtype: tuple + """ + pass + + def groupdict(self, default=None): + """Return a dictionary containing all the named subgroups of the match, + keyed by the subgroup name. + + :rtype: dict[bytes | unicode, T] + """ + pass + + def start(self, group=0): + """Return the index of the start of the substring matched by group. + + :type group: int | bytes | unicode + :rtype: int + """ + pass + + def end(self, group=0): + """Return the index of the end of the substring matched by group. + + :type group: int | bytes | unicode + :rtype: int + """ + pass + + def span(self, group=0): + """Return a 2-tuple (start, end) for the substring matched by group. + + :type group: int | bytes | unicode + :rtype: (int, int) + """ + pass diff --git a/anknotes/addict/__init__.py b/anknotes/addict/__init__.py new file mode 100644 index 0000000..46a0ff9 --- /dev/null +++ b/anknotes/addict/__init__.py @@ -0,0 +1,9 @@ +from .addict import Dict + + +__title__ = 'addict' +__version__ = '0.4.0' +__author__ = 'Mats Julian Olsen' +__license__ = 'MIT' +__copyright__ = 'Copyright 2014 Mats Julian Olsen' +__all__ = ['Dict'] diff --git a/anknotes/addict/addict.py b/anknotes/addict/addict.py new file mode 100644 index 0000000..89361fc --- /dev/null +++ b/anknotes/addict/addict.py @@ -0,0 +1,249 @@ +from inspect import isgenerator +import re +import copy + + +class Dict(dict): + + """ + Dict is a subclass of dict, which allows you to get AND SET(!!) + items in the dict using the attribute syntax! + + When you previously had to write: + + my_dict = {'a': {'b': {'c': [1, 2, 3]}}} + + you can now do the same simply by: + + my_Dict = Dict() + my_Dict.a.b.c = [1, 2, 3] + + Or for instance, if you'd like to add some additional stuff, + where you'd with the normal dict would write + + my_dict['a']['b']['d'] = [4, 5, 6], + + you may now do the AWESOME + + my_Dict.a.b.d = [4, 5, 6] + + instead. But hey, you can always use the same syntax as a regular dict, + however, this will not raise TypeErrors or AttributeErrors at any time + while you try to get an item. A lot like a defaultdict. + + """ + + def __init__(self, *args, **kwargs): + """ + If we're initialized with a dict, make sure we turn all the + subdicts into Dicts as well. + + """ + for arg in args: + if not arg: + continue + elif isinstance(arg, dict): + for key, val in arg.items(): + self[key] = val + elif isinstance(arg, tuple) and (not isinstance(arg[0], tuple)): + self[arg[0]] = arg[1] + elif isinstance(arg, (list, tuple)) or isgenerator(arg): + for key, val in arg: + self[key] = val + else: + raise TypeError("Dict does not understand " + "{0} types".format(type(arg))) + + for key, val in kwargs.items(): + self[key] = val + + def __setattr__(self, name, value): + """ + setattr is called when the syntax a.b = 2 is used to set a value. + + """ + if hasattr(Dict, name): + raise AttributeError("'Dict' object attribute " + "'{0}' is read-only".format(name)) + else: + self[name] = value + + def __setitem__(self, name, value): + """ + This is called when trying to set a value of the Dict using []. + E.g. some_instance_of_Dict['b'] = val. If 'val + + """ + value = self._hook(value) + super(Dict, self).__setitem__(name, value) + + @classmethod + def _hook(cls, item): + """ + Called to ensure that each dict-instance that are being set + is a addict Dict. Recurses. + + """ + if isinstance(item, dict): + return cls(item) + elif isinstance(item, (list, tuple)): + return type(item)(cls._hook(elem) for elem in item) + return item + + def __getattr__(self, item): + return self.__getitem__(item) + + def __getitem__(self, name): + """ + This is called when the Dict is accessed by []. E.g. + some_instance_of_Dict['a']; + If the name is in the dict, we return it. Otherwise we set both + the attr and item to a new instance of Dict. + + """ + if name not in self: + self[name] = Dict() + return super(Dict, self).__getitem__(name) + + def __delattr__(self, name): + """ Is invoked when del some_addict.b is called. """ + del self[name] + + _re_pattern = re.compile('[a-zA-Z_][a-zA-Z0-9_]*') + + def __dir__(self): + """ + Return a list of addict object attributes. + This includes key names of any dict entries, filtered to the subset of + valid attribute names (e.g. alphanumeric strings beginning with a + letter or underscore). Also includes attributes of parent dict class. + """ + dict_keys = [] + for k in self.keys(): + if isinstance(k, str): + m = self._re_pattern.match(k) + if m: + dict_keys.append(m.string) + + obj_attrs = list(dir(Dict)) + + return dict_keys + obj_attrs + + def _ipython_display_(self): + print(str(self)) # pragma: no cover + + def _repr_html_(self): + return str(self) + + def prune(self, prune_zero=False, prune_empty_list=True): + """ + Removes all empty Dicts and falsy stuff inside the Dict. + E.g + >>> a = Dict() + >>> a.b.c.d + {} + >>> a.a = 2 + >>> a + {'a': 2, 'b': {'c': {'d': {}}}} + >>> a.prune() + >>> a + {'a': 2} + + Set prune_zero=True to remove 0 values + E.g + >>> a = Dict() + >>> a.b.c.d = 0 + >>> a.prune(prune_zero=True) + >>> a + {} + + Set prune_empty_list=False to have them persist + E.g + >>> a = Dict({'a': []}) + >>> a.prune() + >>> a + {} + >>> a = Dict({'a': []}) + >>> a.prune(prune_empty_list=False) + >>> a + {'a': []} + """ + for key, val in list(self.items()): + if ((not val) and ((val != 0) or prune_zero) and + not isinstance(val, list)): + del self[key] + elif isinstance(val, Dict): + val.prune(prune_zero, prune_empty_list) + if not val: + del self[key] + elif isinstance(val, (list, tuple)): + new_iter = self._prune_iter(val, prune_zero, prune_empty_list) + if (not new_iter) and prune_empty_list: + del self[key] + else: + if isinstance(val, tuple): + new_iter = tuple(new_iter) + self[key] = new_iter + + @classmethod + def _prune_iter(cls, some_iter, prune_zero=False, prune_empty_list=True): + + new_iter = [] + for item in some_iter: + if item == 0 and prune_zero: + continue + elif isinstance(item, Dict): + item.prune(prune_zero, prune_empty_list) + if item: + new_iter.append(item) + elif isinstance(item, (list, tuple)): + new_item = type(item)( + cls._prune_iter(item, prune_zero, prune_empty_list)) + if new_item or not prune_empty_list: + new_iter.append(new_item) + else: + new_iter.append(item) + return new_iter + + def to_dict(self): + """ Recursively turn your addict Dicts into dicts. """ + base = {} + for key, value in self.items(): + if isinstance(value, type(self)): + base[key] = value.to_dict() + elif isinstance(value, (list, tuple)): + base[key] = type(value)( + item.to_dict() if isinstance(item, type(self)) else + item for item in value) + else: + base[key] = value + return base + + def copy(self): + """ + Return a disconnected deep copy of self. Children of type Dict, list + and tuple are copied recursively while values that are instances of + other mutable objects are not copied. + + """ + return Dict(self.to_dict()) + + def __deepcopy__(self, memo): + """ Return a disconnected deep copy of self. """ + + y = self.__class__() + memo[id(self)] = y + for key, value in self.items(): + y[copy.deepcopy(key, memo)] = copy.deepcopy(value, memo) + return y + + def update(self, d): + """ Recursively merge d into self. """ + + for k, v in d.items(): + if ((k not in self) or + (not isinstance(self[k], dict)) or + (not isinstance(v, dict))): + self[k] = v + else: + self[k].update(v) diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index f985945..75327f0 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -2,38 +2,40 @@ ### Python Imports import socket import stopwatch +from datetime import datetime, timedelta from StringIO import StringIO try: - from lxml import etree - eTreeImported = True + from lxml import etree + import lxml.html as LH + eTreeImported = True except ImportError: - eTreeImported = False + eTreeImported = False try: - from aqt.utils import openLink, getText, showInfo - inAnki = True + from aqt.utils import openLink, getText, showInfo + inAnki = True except ImportError: - inAnki = False - + inAnki = False + try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite ### Anknotes Imports from anknotes.shared import * from anknotes.error import * if inAnki: - ### Anknotes Class Imports - from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher - from anknotes.EvernoteNotePrototype import EvernoteNotePrototype + ### Anknotes Class Imports + from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher + from anknotes.EvernoteNotePrototype import EvernoteNotePrototype - ### Evernote Imports - from anknotes.evernote.edam.type.ttypes import Note as EvernoteNote - from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException - from anknotes.evernote.api.client import EvernoteClient + ### Evernote Imports + from anknotes.evernote.edam.type.ttypes import Note as EvernoteNote + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException + from anknotes.evernote.api.client import EvernoteClient ### Anki Imports # import anki @@ -47,452 +49,460 @@ # from aqt import mw class Evernote(object): - metadata = {} - """:type : dict[str, evernote.edam.type.ttypes.Note]""" - notebook_data = {} - """:type : dict[str, anknotes.structs.EvernoteNotebook]""" - tag_data = {} - """:type : dict[str, anknotes.structs.EvernoteTag]""" - DTD = None - hasValidator = None - - def __init__(self): - global eTreeImported, dbLocal - self.tag_data = {} - self.notebook_data = {} - self.noteStore = None - self.getNoteCount = 0 - self.hasValidator = eTreeImported - if ankDBIsLocal(): - return - auth_token = mw.col.conf.get(SETTINGS.EVERNOTE_AUTH_TOKEN, False) - if not auth_token: - # First run of the Plugin we did not save the access key yet - secrets = {'holycrepe': '36f46ea5dec83d4a', 'scriptkiddi-2682': '965f1873e4df583c'} - client = EvernoteClient( - consumer_key=ANKNOTES.EVERNOTE_CONSUMER_KEY, - consumer_secret=secrets[ANKNOTES.EVERNOTE_CONSUMER_KEY], - sandbox=ANKNOTES.EVERNOTE_IS_SANDBOXED - ) - request_token = client.get_request_token('https://fap-studios.de/anknotes/index.html') - url = client.get_authorize_url(request_token) - showInfo("We will open a Evernote Tab in your browser so you can allow access to your account") - openLink(url) - oauth_verifier = getText(prompt="Please copy the code that showed up, after allowing access, in here")[0] - auth_token = client.get_access_token( - request_token.get('oauth_token'), - request_token.get('oauth_token_secret'), - oauth_verifier) - mw.col.conf[SETTINGS.EVERNOTE_AUTH_TOKEN] = auth_token - self.token = auth_token - self.client = EvernoteClient(token=auth_token, sandbox=ANKNOTES.EVERNOTE_IS_SANDBOXED) - - def initialize_note_store(self): - if self.noteStore: - return EvernoteAPIStatus.Success - api_action_str = u'trying to initialize the Evernote Client.' - log_api("get_note_store") - try: - self.noteStore = self.client.get_note_store() - except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return EvernoteAPIStatus.RateLimitError - raise - except socket.error, v: - if HandleSocketError(v, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return EvernoteAPIStatus.SocketError - raise - return EvernoteAPIStatus.Success - - def validateNoteBody(self, noteBody, title="Note Body"): - # timerFull = stopwatch.Timer() - # timerInterval = stopwatch.Timer(False) - if not self.DTD: - timerInterval = stopwatch.Timer() - log("Loading ENML DTD", "lxml", timestamp=False, do_print=True) - self.DTD = etree.DTD(ANKNOTES.ENML_DTD) - log("DTD Loaded in %s\n" % str(timerInterval), "lxml", timestamp=False, do_print=True) - log(' '*7+' > Note Validation: ENML DTD Loaded in %s' % str(timerInterval)) - timerInterval.stop() - del timerInterval - - # timerInterval.reset() - # log("Loading XML for %s" % title, "lxml", timestamp=False, do_print=False) - try: - tree = etree.parse(StringIO(noteBody)) - except Exception as e: - # timer_header = ' at %s. The whole process took %s' % (str(timerInterval), str(timerFull)) - log_str = "XML Loading of %s failed.\n - Error Details: %s" % (title, str(e)) - log(log_str, "lxml", timestamp=False, do_print=True) - log_error(log_str, False) - return False, log_str - # log("XML Loaded in %s for %s" % (str(timerInterval), title), "lxml", timestamp=False, do_print=False) - # timerInterval.stop() - # timerInterval.reset() - # log("Validating %s with ENML DTD" % title, "lxml", timestamp=False, do_print=False) - try: - success = self.DTD.validate(tree) - except Exception as e: - log_str = "DTD Validation of %s failed.\n - Error Details: %s" % (title, str(e)) - log(log_str, "lxml", timestamp=False, do_print=True) - log_error(log_str, False) - return False, log_str - log("Validation %-9s for %s" % ("Succeeded" if success else "Failed", title), "lxml", timestamp=False, - do_print=True) - errors = self.DTD.error_log.filter_from_errors() - if not success: - log_str = "DTD Validation Errors for %s: \n%s\n" % (title, errors) - log(log_str, "lxml", timestamp=False) - log_error(log_str, False) - # timerInterval.stop() - # timerFull.stop() - # del timerInterval - # del timerFull - return success, errors - - def validateNoteContent(self, content, title="Note Contents"): - """ - - :param content: Valid ENML without the <en-note></en-note> tags. Will be processed by makeNoteBody - :return: - """ - return self.validateNoteBody(self.makeNoteBody(content), title) - - def updateNote(self, guid, noteTitle, noteBody, tagNames=list(), parentNotebook=None, resources=None): - """ - Update a Note instance with title and body - Send Note object to user's account - :rtype : (EvernoteAPIStatus, evernote.edam.type.ttypes.Note) - :returns Status and Note - """ - if resources is None: resources = [] - return self.makeNote(noteTitle, noteBody, tagNames=tagNames, parentNotebook=parentNotebook, resources=resources, - guid=guid) - - @staticmethod - def makeNoteBody(content, resources=None, encode=True): - ## Build body of note - if resources is None: resources = [] - nBody = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" - nBody += "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" - nBody += "<en-note>%s" % content - # if resources: - # ### Add Resource objects to note body - # nBody += "<br />" * 2 - # ourNote.resources = resources - # for resource in resources: - # hexhash = binascii.hexlify(resource.data.bodyHash) - # nBody += "Attachment with hash %s: <br /><en-media type=\"%s\" hash=\"%s\" /><br />" % \ - # (hexhash, resource.mime, hexhash) - nBody += "</en-note>" - if encode: - nBody = nBody.encode('utf-8') - return nBody - - @staticmethod - def addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=None, - guid=None): - if resources is None: resources = [] - sql = "FROM %s WHERE " % TABLES.MAKE_NOTE_QUEUE - if guid: - sql += "guid = '%s'" % guid - else: - sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) - statuses = ankDB().all('SELECT validation_status ' + sql) - if len(statuses) > 0: - if str(statuses[0]['validation_status']) == '1': return EvernoteAPIStatus.Success - ankDB().execute("DELETE " + sql) - # log_sql(sql) - # log_sql([ guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook]) - ankDB().execute( - "INSERT INTO %s(guid, title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?, ?)" % TABLES.MAKE_NOTE_QUEUE, - guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook) - return EvernoteAPIStatus.RequestQueued - - def makeNote(self, noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=None, guid=None, - validated=None): - """ - Create or Update a Note instance with title and body - Send Note object to user's account - :type noteTitle: str - :param noteContents: Valid ENML without the <en-note></en-note> tags. Will be processed by makeNoteBody - :rtype : (EvernoteAPIStatus, EvernoteNote) - :returns Status and Note - """ - if resources is None: resources = [] - callType = "create" - validation_status = EvernoteAPIStatus.Uninitialized - if validated is None: - if not ANKNOTES.ENABLE_VALIDATION: - validated = True - else: - validation_status = self.addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames, parentNotebook, - resources, guid) - if not validation_status.IsSuccess and not self.hasValidator: - return validation_status, None - - ourNote = EvernoteNote() - ourNote.title = noteTitle.encode('utf-8') - if guid: - callType = "update" - ourNote.guid = guid - - ## Build body of note - nBody = self.makeNoteBody(noteContents, resources) - if not validated is True and not validation_status.IsSuccess: - success, errors = self.validateNoteBody(nBody, ourNote.title) - if not success: - return EvernoteAPIStatus.UserError, None - ourNote.content = nBody - - self.initialize_note_store() - - while '' in tagNames: tagNames.remove('') - if len(tagNames) > 0: - if ANKNOTES.EVERNOTE_IS_SANDBOXED and not '#Sandbox' in tagNames: - tagNames.append("#Sandbox") - ourNote.tagNames = tagNames - - ## parentNotebook is optional; if omitted, default notebook is used - if parentNotebook: - if hasattr(parentNotebook, 'guid'): - ourNote.notebookGuid = parentNotebook.guid - elif isinstance(parentNotebook, str) or isinstance(parentNotebook, unicode): - ourNote.notebookGuid = parentNotebook - - ## Attempt to create note in Evernote account - - api_action_str = u'trying to %s a note' % callType - log_api(callType + "Note", "'%s'" % noteTitle) - try: - note = getattr(self.noteStore, callType + 'Note')(self.token, ourNote) - except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return EvernoteAPIStatus.RateLimitError, None - except socket.error, v: - if HandleSocketError(v, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return EvernoteAPIStatus.SocketError, None - except EDAMUserException, edue: - ## Something was wrong with the note data - ## See EDAMErrorCode enumeration for error code explanation - ## http://dev.evernote.com/documentation/reference/Errors.html#Enum_EDAMErrorCode - print "EDAMUserException:", edue - log_error("-------------------------------------------------") - log_error("EDAMUserException: " + str(edue)) - log_error(str(ourNote.tagNames)) - log_error(str(ourNote.content)) - log_error("-------------------------------------------------\r\n") - if DEBUG_RAISE_API_ERRORS: raise - return EvernoteAPIStatus.UserError, None - except EDAMNotFoundException, ednfe: - print "EDAMNotFoundException:", ednfe - log_error("-------------------------------------------------") - log_error("EDAMNotFoundException: " + str(ednfe)) - if callType is "update": - log_error(str(ourNote.guid)) - if ourNote.notebookGuid: - log_error(str(ourNote.notebookGuid)) - log_error("-------------------------------------------------\r\n") - if DEBUG_RAISE_API_ERRORS: raise - return EvernoteAPIStatus.NotFoundError, None - except Exception, e: - print "Unknown Exception:", e - log_error("-------------------------------------------------") - log_error("Unknown Exception: " + str(e)) - log_error(str(ourNote.tagNames)) - log_error(str(ourNote.content)) - log_error("-------------------------------------------------\r\n") - # return EvernoteAPIStatus.UnhandledError, None - raise - # noinspection PyUnboundLocalVariable - note.content = nBody - return EvernoteAPIStatus.Success, note - - def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): - global inAnki - """ - Create EvernoteNote objects from Evernote GUIDs using EvernoteNoteFetcher.getNote(). - Will prematurely return if fetcher.getNote fails - - :rtype : EvernoteNoteFetcherResults - :param evernote_guids: - :param use_local_db_only: Do not initiate API calls - :return: EvernoteNoteFetcherResults - """ - if not hasattr(self, 'guids') or evernote_guids: self.evernote_guids = evernote_guids - if not use_local_db_only: - self.check_ancillary_data_up_to_date() - fetcher = EvernoteNoteFetcher(self, use_local_db_only=use_local_db_only) - if len(evernote_guids) == 0: - fetcher.results.Status = EvernoteAPIStatus.EmptyRequest - return fetcher.results - if inAnki: - fetcher.evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE).split() - fetcher.keepEvernoteTags = mw.col.conf.get(SETTINGS.KEEP_EVERNOTE_TAGS, SETTINGS.KEEP_EVERNOTE_TAGS_DEFAULT_VALUE) - fetcher.deleteQueryTags = mw.col.conf.get(SETTINGS.DELETE_EVERNOTE_TAGS_TO_IMPORT, True) - fetcher.tagsToDelete = mw.col.conf.get(SETTINGS.EVERNOTE_TAGS_TO_DELETE, "").split() - for evernote_guid in self.evernote_guids: - self.evernote_guid = evernote_guid - if not fetcher.getNote(evernote_guid): - return fetcher.results - return fetcher.results - - def check_ancillary_data_up_to_date(self): - if not self.check_tags_up_to_date(): - self.update_tags_db("Tags were not up to date when checking ancillary data") - if not self.check_notebooks_up_to_date(): - self.update_notebook_db() - - def update_ancillary_data(self): - self.update_tags_db("Manual call to update ancillary data") - self.update_notebook_db() - - def check_notebook_metadata(self, notes): - """ - :param notes: - :type : list[EvernoteNotePrototype] - :return: - """ - if not hasattr(self, 'notebook_data'): - self.notebook_data = {x.guid:{'stack': x.stack, 'name': x.name} for x in ankDB().execute("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS) } - for note in notes: - assert(isinstance(note, EvernoteNotePrototype)) - if not note.NotebookGuid in self.notebook_data: - self.update_notebook_db() - if not note.NotebookGuid in self.notebook_data: - log_error("FATAL ERROR: Notebook GUID %s for Note %s: %s does not exist on Evernote servers" % (note.NotebookGuid, note.Guid, note.Title)) - raise EDAMNotFoundException() - return False - return True - - def check_notebooks_up_to_date(self): - for evernote_guid in self.evernote_guids: - note_metadata = self.metadata[evernote_guid] - notebookGuid = note_metadata.notebookGuid - if not notebookGuid: - log_error(" > Notebook check: Unable to find notebook guid for '%s'. Returned '%s'. Metadata: %s" % ( - evernote_guid, str(notebookGuid), str(note_metadata)), crossPost=False) - elif notebookGuid not in self.notebook_data: - nb = EvernoteNotebook(fetch_guid=notebookGuid) - if not nb.success: - log(" > Notebook check: Missing notebook guid '%s'. Will update with an API call." % notebookGuid) - return False - self.notebook_data[notebookGuid] = nb - return True - - def update_notebook_db(self): - self.initialize_note_store() - api_action_str = u'trying to update Evernote notebooks.' - log_api("listNotebooks") - try: - notebooks = self.noteStore.listNotebooks(self.token) - except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return None - raise - except socket.error, v: - if HandleSocketError(v, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return None - raise - data = [] - for notebook in notebooks: - self.notebook_data[notebook.guid] = {"stack": notebook.stack, "name": notebook.name} - data.append( - [notebook.guid, notebook.name, notebook.updateSequenceNum, notebook.serviceUpdated, notebook.stack]) - ankDB().execute("DROP TABLE %s " % TABLES.EVERNOTE.NOTEBOOKS) - ankDB().InitNotebooks(True) - log_dump(data, 'update_notebook_db table data') - ankDB().executemany( - "INSERT INTO `%s`(`guid`,`name`,`updateSequenceNum`,`serviceUpdated`, `stack`) VALUES (?, ?, ?, ?, ?)" % TABLES.EVERNOTE.NOTEBOOKS, - data) - log_dump(ankDB().all("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS), 'sql data') - - def check_tags_up_to_date(self): - for evernote_guid in self.evernote_guids: - if evernote_guid not in self.metadata: - log_error('Could not find note metadata for Note ''%s''' % evernote_guid) - return False - else: - note_metadata = self.metadata[evernote_guid] - if not note_metadata.tagGuids: continue - for tag_guid in note_metadata.tagGuids: - if tag_guid not in self.tag_data: - tag = EvernoteTag(fetch_guid=tag_guid) - if not tag.success: - return False - self.tag_data[tag_guid] = tag - return True - - def update_tags_db(self, reason_str=''): - self.initialize_note_store() - api_action_str = u'trying to update Evernote tags.' - log_api("listTags" + (': ' + reason_str) if reason_str else '') - - try: - tags = self.noteStore.listTags(self.token) - """: type : list[evernote.edam.type.ttypes.Tag] """ - except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return None - raise - except socket.error, v: - if HandleSocketError(v, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return None - raise - data = [] - if not hasattr(self, 'tag_data'): self.tag_data = {} - enTag = None - for tag in tags: - enTag = EvernoteTag(tag) - self.tag_data[enTag.Guid] = enTag - data.append(enTag.items()) - if not enTag: return None - ankDB().execute("DROP TABLE %s " % TABLES.EVERNOTE.TAGS) - ankDB().InitTags(True) - ankDB().executemany(enTag.sqlUpdateQuery(), data) - ankDB().commit() - - def set_tag_data(self): - if not hasattr(self, 'tag_data'): - self.tag_data = {x.guid: EvernoteTag(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS)} - - def get_missing_tags(self, current_tags, from_guids=True): - if isinstance(current_tags, list): current_tags = set(current_tags) - return current_tags - set(self.tag_data.keys() if from_guids else [v.Name for k, v in self.tag_data.items()]) - - def get_matching_tag_data(self, tag_guids=None, tag_names=None): - tagGuids = [] - tagNames = [] - self.set_tag_data() - assert tag_guids or tag_names - from_guids = True if (tag_guids is not None) else False - tags_original = tag_guids if from_guids else tag_names - if self.get_missing_tags(tags_original, from_guids): - self.update_tags_db("Missing Tags Were found when attempting to get matching tag data for tags: %s" % ', '.join(tags_original)) - missing_tags = self.get_missing_tags(tags_original, from_guids) - if missing_tags: - log_error("FATAL ERROR: Tag %s(s) %s were not found on the Evernote Servers" % ('Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)))) - raise EDAMNotFoundException() - if from_guids: tags_dict = {x: self.tag_data[x] for x in tags_original} - else: tags_dict = {[k for k, v in self.tag_data.items() if v.Name is tag_name][0]: tag_name for tag_name in tags_original} - tagNamesToImport = get_tag_names_to_import(tags_dict) - """:type : dict[string, EvernoteTag]""" - if tagNamesToImport: - is_struct = None - for k, v in tagNamesToImport.items(): - if is_struct is None: is_struct = isinstance(v, EvernoteTag) - tagGuids.append(k) - tagNames.append(v.Name if is_struct else v) - tagNames = sorted(tagNames, key=lambda s: s.lower()) - return tagGuids, tagNames + metadata = {} + """:type : dict[str, evernote.edam.type.ttypes.Note]""" + notebook_data = {} + """:type : dict[str, anknotes.structs.EvernoteNotebook]""" + tag_data = {} + """:type : dict[str, anknotes.structs.EvernoteTag]""" + DTD = None + hasValidator = None + + def __init__(self): + global eTreeImported, dbLocal + self.tag_data = {} + self.notebook_data = {} + self.noteStore = None + self.getNoteCount = 0 + self.hasValidator = eTreeImported + if ankDBIsLocal(): + return + auth_token = mw.col.conf.get(SETTINGS.EVERNOTE.AUTH_TOKEN, False) + if not auth_token: + # First run of the Plugin we did not save the access key yet + secrets = {'holycrepe': '36f46ea5dec83d4a', 'scriptkiddi-2682': '965f1873e4df583c'} + client = EvernoteClient( + consumer_key=EVERNOTE.API.CONSUMER_KEY, + consumer_secret=secrets[EVERNOTE.API.CONSUMER_KEY], + sandbox=EVERNOTE.API.IS_SANDBOXED + ) + request_token = client.get_request_token('https://fap-studios.de/anknotes/index.html') + url = client.get_authorize_url(request_token) + showInfo("We will open a Evernote Tab in your browser so you can allow access to your account") + openLink(url) + oauth_verifier = getText(prompt="Please copy the code that showed up, after allowing access, in here")[0] + auth_token = client.get_access_token( + request_token.get('oauth_token'), + request_token.get('oauth_token_secret'), + oauth_verifier) + mw.col.conf[SETTINGS.EVERNOTE.AUTH_TOKEN] = auth_token + self.token = auth_token + self.client = EvernoteClient(token=auth_token, sandbox=EVERNOTE.API.IS_SANDBOXED) + + def initialize_note_store(self): + if self.noteStore: + return EvernoteAPIStatus.Success + api_action_str = u'trying to initialize the Evernote Client.' + log_api("get_note_store") + try: + self.noteStore = self.client.get_note_store() + except EDAMSystemException as e: + if HandleEDAMRateLimitError(e, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + return EvernoteAPIStatus.RateLimitError + raise + except socket.error, v: + if HandleSocketError(v, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + return EvernoteAPIStatus.SocketError + raise + return EvernoteAPIStatus.Success + + def validateNoteBody(self, noteBody, title="Note Body"): + # timerFull = stopwatch.Timer() + # timerInterval = stopwatch.Timer(False) + if not self.DTD: + timerInterval = stopwatch.Timer() + log("Loading ENML DTD", "lxml", timestamp=False, do_print=True) + self.DTD = etree.DTD(FILES.ANCILLARY.ENML_DTD) + log("DTD Loaded in %s\n" % str(timerInterval), "lxml", timestamp=False, do_print=True) + log(' '*7+' > Note Validation: ENML DTD Loaded in %s' % str(timerInterval)) + timerInterval.stop() + del timerInterval + + noteBody = noteBody.replace('"http://xml.evernote.com/pub/enml2.dtd"', '"%s"' % convert_filename_to_local_link(FILES.ANCILLARY.ENML_DTD) ) + parser = etree.XMLParser(dtd_validation=True, attribute_defaults=True) + try: + root = etree.fromstring(noteBody, parser) + except Exception as e: + log_str = "XML Loading of %s failed.\n - Error Details: %s" % (title, str(e)) + log(log_str, "lxml", timestamp=False, do_print=True) + log_error(log_str, False) + return False, [log_str] + try: + success = self.DTD.validate(root) + except Exception as e: + log_str = "DTD Validation of %s failed.\n - Error Details: %s" % (title, str(e)) + log(log_str, "lxml", timestamp=False, do_print=True) + log_error(log_str, False) + return False, [log_str] + log("Validation %-9s for %s" % ("Succeeded" if success else "Failed", title), "lxml", timestamp=False, + do_print=True) + errors = [str(x) for x in self.DTD.error_log.filter_from_errors()] + if not success: + log_str = "DTD Validation Errors for %s: \n%s\n" % (title, str(errors)) + log(log_str, "lxml", timestamp=False) + log_error(log_str, False) + return success, errors + + def validateNoteContent(self, content, title="Note Contents"): + """ + + :param content: Valid ENML without the <en-note></en-note> tags. Will be processed by makeNoteBody + :return: + """ + return self.validateNoteBody(self.makeNoteBody(content), title) + + def updateNote(self, guid, noteTitle, noteBody, tagNames=list(), parentNotebook=None, resources=None): + """ + Update a Note instance with title and body + Send Note object to user's account + :rtype : (EvernoteAPIStatus, evernote.edam.type.ttypes.Note) + :returns Status and Note + """ + if resources is None: resources = [] + return self.makeNote(noteTitle, noteBody, tagNames=tagNames, parentNotebook=parentNotebook, resources=resources, + guid=guid) + + @staticmethod + def makeNoteBody(content, resources=None, encode=True): + ## Build body of note + if resources is None: resources = [] + nBody = content + if not nBody.startswith("<?xml"): + nBody = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + nBody += "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" + nBody += "<en-note>%s" % content + # if resources: + # ### Add Resource objects to note body + # nBody += "<br />" * 2 + # ourNote.resources = resources + # for resource in resources: + # hexhash = binascii.hexlify(resource.data.bodyHash) + # nBody += "Attachment with hash %s: <br /><en-media type=\"%s\" hash=\"%s\" /><br />" % \ + # (hexhash, resource.mime, hexhash) + nBody += "</en-note>" + if encode and isinstance(nBody, unicode): + nBody = nBody.encode('utf-8') + return nBody + + @staticmethod + def addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=None, + guid=None): + if resources is None: resources = [] + sql = "FROM %s WHERE " % TABLES.NOTE_VALIDATION_QUEUE + if guid: + sql += "guid = '%s'" % guid + else: + sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) + statuses = ankDB().all('SELECT validation_status ' + sql) + if len(statuses) > 0: + if str(statuses[0]['validation_status']) == '1': return EvernoteAPIStatus.Success + ankDB().execute("DELETE " + sql) + # log_sql(sql) + # log_sql([ guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook]) + ankDB().execute( + "INSERT INTO %s(guid, title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?, ?)" % TABLES.NOTE_VALIDATION_QUEUE, + guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook) + return EvernoteAPIStatus.RequestQueued + + def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNotebook=None, resources=None, guid=None, + validated=None, enNote=None): + """ + Create or Update a Note instance with title and body + Send Note object to user's account + :type noteTitle: str + :param noteContents: Valid ENML without the <en-note></en-note> tags. Will be processed by makeNoteBody + :type enNote : EvernoteNotePrototype + :rtype : (EvernoteAPIStatus, EvernoteNote) + :returns Status and Note + """ + if enNote: + noteTitle = enNote.Title.FullTitle + noteContents = enNote.Content + tagNames = enNote.Tags + if enNote.NotebookGuid: parentNotebook = enNote.NotebookGuid + guid = enNote.Guid + + if resources is None: resources = [] + callType = "create" + validation_status = EvernoteAPIStatus.Uninitialized + if validated is None: + if not EVERNOTE.UPLOAD.VALIDATION.ENABLED: + validated = True + else: + validation_status = self.addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames, parentNotebook, + resources, guid) + if not validation_status.IsSuccess and not self.hasValidator: + return validation_status, None + + ourNote = EvernoteNote() + ourNote.title = noteTitle.encode('utf-8') + if guid: + callType = "update" + ourNote.guid = guid + + ## Build body of note + nBody = self.makeNoteBody(noteContents, resources) + if not validated is True and not validation_status.IsSuccess: + success, errors = self.validateNoteBody(nBody, ourNote.title) + if not success: + return EvernoteAPIStatus.UserError, None + ourNote.content = nBody + + self.initialize_note_store() + + while '' in tagNames: tagNames.remove('') + if len(tagNames) > 0: + if EVERNOTE.API.IS_SANDBOXED and not '#Sandbox' in tagNames: + tagNames.append("#Sandbox") + ourNote.tagNames = tagNames + + ## parentNotebook is optional; if omitted, default notebook is used + if parentNotebook: + if hasattr(parentNotebook, 'guid'): + ourNote.notebookGuid = parentNotebook.guid + elif isinstance(parentNotebook, str) or isinstance(parentNotebook, unicode): + ourNote.notebookGuid = parentNotebook + + ## Attempt to create note in Evernote account + + api_action_str = u'trying to %s a note' % callType + log_api(callType + "Note", "'%s'" % noteTitle) + try: + note = getattr(self.noteStore, callType + 'Note')(self.token, ourNote) + except EDAMSystemException as e: + if HandleEDAMRateLimitError(e, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + return EvernoteAPIStatus.RateLimitError, None + except socket.error, v: + if HandleSocketError(v, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + return EvernoteAPIStatus.SocketError, None + except EDAMUserException, edue: + ## Something was wrong with the note data + ## See EDAMErrorCode enumeration for error code explanation + ## http://dev.evernote.com/documentation/reference/Errors.html#Enum_EDAMErrorCode + print "EDAMUserException:", edue + log_error("-------------------------------------------------") + log_error("EDAMUserException: " + str(edue)) + log_error(str(ourNote.tagNames)) + log_error(str(ourNote.content)) + log_error("-------------------------------------------------\r\n") + if DEBUG_RAISE_API_ERRORS: raise + return EvernoteAPIStatus.UserError, None + except EDAMNotFoundException, ednfe: + print "EDAMNotFoundException:", ednfe + log_error("-------------------------------------------------") + log_error("EDAMNotFoundException: " + str(ednfe)) + if callType is "update": + log_error(str(ourNote.guid)) + if ourNote.notebookGuid: + log_error(str(ourNote.notebookGuid)) + log_error("-------------------------------------------------\r\n") + if DEBUG_RAISE_API_ERRORS: raise + return EvernoteAPIStatus.NotFoundError, None + except Exception, e: + print "Unknown Exception:", e + log_error("-------------------------------------------------") + log_error("Unknown Exception: " + str(e)) + log_error(str(ourNote.tagNames)) + log_error(str(ourNote.content)) + log_error("-------------------------------------------------\r\n") + # return EvernoteAPIStatus.UnhandledError, None + raise + # noinspection PyUnboundLocalVariable + note.content = nBody + return EvernoteAPIStatus.Success, note + + def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): + global inAnki + """ + Create EvernoteNote objects from Evernote GUIDs using EvernoteNoteFetcher.getNote(). + Will prematurely return if fetcher.getNote fails + + :rtype : EvernoteNoteFetcherResults + :param evernote_guids: + :param use_local_db_only: Do not initiate API calls + :return: EvernoteNoteFetcherResults + """ + if not hasattr(self, 'guids') or evernote_guids: self.evernote_guids = evernote_guids + if not use_local_db_only: + self.check_ancillary_data_up_to_date() + fetcher = EvernoteNoteFetcher(self, use_local_db_only=use_local_db_only) + if len(evernote_guids) == 0: + fetcher.results.Status = EvernoteAPIStatus.EmptyRequest + return fetcher.results + if inAnki: + fetcher.evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).split() + fetcher.keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS., SETTINGS.ANKI.TAGS.KEEP_TAGS._DEFAULT_VALUE) + fetcher.deleteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, True) + fetcher.tagsToDelete = mw.col.conf.get(SETTINGS.TAGS.TO_DELETE, "").split() + for evernote_guid in self.evernote_guids: + self.evernote_guid = evernote_guid + if not fetcher.getNote(evernote_guid): + return fetcher.results + return fetcher.results + + def check_ancillary_data_up_to_date(self): + if not self.check_tags_up_to_date(): + self.update_tags_db("Tags were not up to date when checking ancillary data") + if not self.check_notebooks_up_to_date(): + self.update_notebook_db() + + def update_ancillary_data(self): + self.update_tags_db("Manual call to update ancillary data") + self.update_notebook_db() + + def check_notebook_metadata(self, notes): + """ + :param notes: + :type : list[EvernoteNotePrototype] + :return: + """ + if not hasattr(self, 'notebook_data'): + self.notebook_data = {x.guid:{'stack': x.stack, 'name': x.name} for x in ankDB().execute("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS) } + for note in notes: + assert(isinstance(note, EvernoteNotePrototype)) + if not note.NotebookGuid in self.notebook_data: + self.update_notebook_db() + if not note.NotebookGuid in self.notebook_data: + log_error("FATAL ERROR: Notebook GUID %s for Note %s: %s does not exist on Evernote servers" % (note.NotebookGuid, note.Guid, note.Title)) + raise EDAMNotFoundException() + return False + return True + + def check_notebooks_up_to_date(self): + for evernote_guid in self.evernote_guids: + note_metadata = self.metadata[evernote_guid] + notebookGuid = note_metadata.notebookGuid + if not notebookGuid: + log_error(" > Notebook check: Unable to find notebook guid for '%s'. Returned '%s'. Metadata: %s" % ( + evernote_guid, str(notebookGuid), str(note_metadata)), crossPost=False) + elif notebookGuid not in self.notebook_data: + nb = EvernoteNotebook(fetch_guid=notebookGuid) + if not nb.success: + log(" > Notebook check: Missing notebook guid '%s'. Will update with an API call." % notebookGuid) + return False + self.notebook_data[notebookGuid] = nb + return True + + def update_notebook_db(self): + self.initialize_note_store() + api_action_str = u'trying to update Evernote notebooks.' + log_api("listNotebooks") + try: + notebooks = self.noteStore.listNotebooks(self.token) + except EDAMSystemException as e: + if HandleEDAMRateLimitError(e, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + return None + raise + except socket.error, v: + if HandleSocketError(v, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + return None + raise + data = [] + for notebook in notebooks: + self.notebook_data[notebook.guid] = {"stack": notebook.stack, "name": notebook.name} + data.append( + [notebook.guid, notebook.name, notebook.updateSequenceNum, notebook.serviceUpdated, notebook.stack]) + ankDB().execute("DROP TABLE %s " % TABLES.EVERNOTE.NOTEBOOKS) + ankDB().InitNotebooks(True) + log_dump(data, 'update_notebook_db table data') + ankDB().executemany( + "INSERT INTO `%s`(`guid`,`name`,`updateSequenceNum`,`serviceUpdated`, `stack`) VALUES (?, ?, ?, ?, ?)" % TABLES.EVERNOTE.NOTEBOOKS, + data) + log_dump(ankDB().all("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS), 'sql data') + + def check_tags_up_to_date(self): + for evernote_guid in self.evernote_guids: + if evernote_guid not in self.metadata: + log_error('Could not find note metadata for Note ''%s''' % evernote_guid) + return False + else: + note_metadata = self.metadata[evernote_guid] + if not note_metadata.tagGuids: continue + for tag_guid in note_metadata.tagGuids: + if tag_guid not in self.tag_data: + tag = EvernoteTag(fetch_guid=tag_guid) + if not tag.success: + return False + self.tag_data[tag_guid] = tag + return True + + def update_tags_db(self, reason_str=''): + if hasattr(self, 'LastTagDBUpdate') and datetime.now() - self.LastTagDBUpdate < timedelta(minutes=15): + return None + self.LastTagDBUpdate = datetime.now() + self.initialize_note_store() + api_action_str = u'trying to update Evernote tags.' + log_api("listTags" + (': ' + reason_str) if reason_str else '') + + try: + tags = self.noteStore.listTags(self.token) + """: type : list[evernote.edam.type.ttypes.Tag] """ + except EDAMSystemException as e: + if HandleEDAMRateLimitError(e, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + return None + raise + except socket.error, v: + if HandleSocketError(v, api_action_str): + if DEBUG_RAISE_API_ERRORS: raise + return None + raise + data = [] + if not hasattr(self, 'tag_data'): self.tag_data = {} + enTag = None + for tag in tags: + enTag = EvernoteTag(tag) + self.tag_data[enTag.Guid] = enTag + data.append(enTag.items()) + if not enTag: return None + ankDB().execute("DROP TABLE %s " % TABLES.EVERNOTE.TAGS) + ankDB().InitTags(True) + ankDB().executemany(enTag.sqlUpdateQuery(), data) + ankDB().commit() + + def set_tag_data(self): + if not hasattr(self, 'tag_data') or not self.tag_data or len(self.tag_data.keys()) == 0: + self.tag_data = {x['guid']: EvernoteTag(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS)} + + def get_missing_tags(self, current_tags, from_guids=True): + if isinstance(current_tags, list): current_tags = set(current_tags) + self.set_tag_data() + all_tags = set(self.tag_data.keys() if from_guids else [v.Name for k, v in self.tag_data.items()]) + missing_tags = current_tags - all_tags + if missing_tags: + log_error("Missing Tag %s(s) were found:\nMissing: %s\n\nCurrent: %s\n\nAll Tags: %s\n\nTag Data: %s" % ('Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)), ', '.join(sorted(current_tags)), ', '.join(sorted(all_tags)), str(self.tag_data))) + return missing_tags + + def get_matching_tag_data(self, tag_guids=None, tag_names=None): + tagGuids = [] + tagNames = [] + assert tag_guids or tag_names + from_guids = True if (tag_guids is not None) else False + tags_original = tag_guids if from_guids else tag_names + if self.get_missing_tags(tags_original, from_guids): + self.update_tags_db("Missing Tag %s(s) Were found when attempting to get matching tag data" % ('Guids' if from_guids else 'Names')) + missing_tags = self.get_missing_tags(tags_original, from_guids) + if missing_tags: + log_error("FATAL ERROR: Tag %s(s) %s were not found on the Evernote Servers" % ('Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)))) + raise EDAMNotFoundException() + if from_guids: tags_dict = {x: self.tag_data[x] for x in tags_original} + else: tags_dict = {[k for k, v in self.tag_data.items() if v.Name is tag_name][0]: tag_name for tag_name in tags_original} + tagNamesToImport = get_tag_names_to_import(tags_dict) + """:type : dict[string, EvernoteTag]""" + if tagNamesToImport: + is_struct = None + for k, v in tagNamesToImport.items(): + if is_struct is None: is_struct = isinstance(v, EvernoteTag) + tagGuids.append(k) + tagNames.append(v.Name if is_struct else v) + tagNames = sorted(tagNames, key=lambda s: s.lower()) + return tagGuids, tagNames DEBUG_RAISE_API_ERRORS = False diff --git a/anknotes/bare.py b/anknotes/bare.py deleted file mode 100644 index 652ddac..0000000 --- a/anknotes/bare.py +++ /dev/null @@ -1,233 +0,0 @@ -# -*- coding: utf-8 -*- -import shutil - -try: - from pysqlite2 import dbapi2 as sqlite -except ImportError: - from sqlite3 import dbapi2 as sqlite - -from anknotes.shared import * -from anknotes import stopwatch -from anknotes.stopwatch import clockit -import re -from anknotes._re import __Match - -from anknotes.EvernoteNotes import EvernoteNotes -from anknotes.AnkiNotePrototype import AnkiNotePrototype -from enum import Enum -from anknotes.enums import * -from anknotes.structs import EvernoteAPIStatus - -Error = sqlite.Error -ankDBSetLocal() -NotesDB = EvernoteNotes() - - -class notes: - class version(object): - class pstrings: - __updated__ = None - __processed__ = None - __original__ = None - __regex_updated__ = None - """: type : notes.version.see_also_match """ - __regex_processed__ = None - """: type : notes.version.see_also_match """ - __regex_original__ = None - """: type : notes.version.see_also_match """ - - @property - def regex_original(self): - if self.original is None: return None - if self.__regex_original__ is None: - self.__regex_original__ = notes.version.see_also_match(self.original) - return self.__regex_original__ - - @property - def regex_processed(self): - if self.processed is None: return None - if self.__regex_processed__ is None: - self.__regex_processed__ = notes.version.see_also_match(self.processed) - return self.__regex_processed__ - - @property - def regex_updated(self): - if self.updated is None: return None - if self.__regex_updated__ is None: - self.__regex_updated__ = notes.version.see_also_match(self.updated) - return self.__regex_updated__ - - @property - def processed(self): - if self.__processed__ is None: - self.__processed__ = str_process(self.original) - return self.__processed__ - - @property - def updated(self): - if self.__updated__ is None: return self.__original__ - return self.__updated__ - - @updated.setter - def updated(self, value): - self.__regex_updated__ = None - self.__updated__ = value - - @property - def original(self): - return self.__original__ - - def useProcessed(self): - self.updated = self.processed - - def __init__(self, original=None): - self.__original__ = original - - class see_also_match(object): - __subject__ = None - __content__ = None - __matchobject__ = None - """:type : __Match """ - __match_attempted__ = 0 - - @property - def subject(self): - if not self.__subject__: return self.content - return self.__subject__ - - @subject.setter - def subject(self, value): - self.__subject__ = value - self.__match_attempted__ = 0 - self.__matchobject__ = None - - @property - def content(self): - return self.__content__ - - def groups(self, group=0): - """ - :param group: - :type group : int | str | unicode - :return: - """ - if not self.successful_match: - return None - return self.__matchobject__.group(group) - - @property - def successful_match(self): - if self.__matchobject__: return True - if self.__match_attempted__ is 0 and self.subject is not None: - self.__matchobject__ = notes.rgx.search(self.subject) - """:type : __Match """ - self.__match_attempted__ += 1 - return self.__matchobject__ is not None - - @property - def main(self): - return self.groups(0) - - @property - def see_also(self): - return self.groups('SeeAlso') - - @property - def see_also_content(self): - return self.groups('SeeAlsoContent') - - def __init__(self, content=None): - """ - - :type content: str | unicode - """ - self.__content__ = content - self.__match_attempted__ = 0 - self.__matchobject__ = None - """:type : __Match """ - content = pstrings() - see_also = pstrings() - old = version() - new = version() - rgx = regex_see_also() - match_type = 'NA' - - -def str_process(strr): - strr = strr.replace(u"evernote:///", u"evernote://") - strr = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', - r'evernote://view/\2/\1/\3/\3/', strr) - strr = strr.replace(u"evernote://", u"evernote:///").replace(u'<BR>', u'<br />') - strr = re.sub(r'<br ?/?>', u'<br/>', strr, 0, re.IGNORECASE) - strr = re.sub(r'<<<span class="occluded">(.+?)</span>>>', r'<<\1>>', strr) - strr = strr.replace('<span class="occluded">', '<span style="color: rgb(255, 255, 255);">') - return strr - -def main_bare(): - @clockit - def print_results(): - diff = generate_diff(n.old.see_also.updated, n.new.see_also.updated) - log.plain(diff, 'SeeAlsoDiff\\Diff\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) - log.plain(diffify(n.old.see_also.updated,split=False), 'SeeAlsoDiff\\Original\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) - log.plain(diffify(n.new.see_also.updated,split=False), 'SeeAlsoDiff\\New\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) - log.plain(diff + '\n', 'SeeAlsoDiff\\__All') - - @clockit - def process_note(): - n.old.content = notes.version.pstrings(enNote.Content) - if not n.old.content.regex_original.successful_match: - n.new.content = notes.version.pstrings(n.old.content.original.replace('</en-note>', '<div><span><br/></span></div>' + n.new.see_also.original + '\n</en-note>')) - n.new.see_also.updated = str_process(n.new.content.original) - n.old.see_also.updated = str_process(n.old.content.original) - log.plain((target_evernote_guid + '<BR>' if target_evernote_guid != enNote.Guid else '') + enNote.Guid + '<BR>' + ', '.join(enNote.TagNames) + '<HR>' + enNote.Content + '<HR>' + n.new.see_also.updated, 'SeeAlsoMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) - n.match_type = 'V1' - else: - n.old.see_also = notes.version.pstrings(n.old.content.regex_original.main) - n.match_type = 'V2' - if n.old.see_also.regex_processed.successful_match: - assert True or str_process(n.old.content.regex_original.main) is n.old.content.regex_processed.main - n.old.content.updated = n.old.content.original.replace(n.old.content.regex_original.main, str_process(n.old.content.regex_original.main)) - n.old.see_also.useProcessed() - n.match_type += 'V3' - n.new.see_also.regex_original.subject = n.new.see_also.original + '</en-note>' - if not n.new.see_also.regex_original.successful_match: - log.plain(enNote.Guid + '\n' + ', '.join(enNote.TagNames) + '\n' + n.new.see_also.original.content, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) - see_also_replace_old = n.old.content.original.match.processed.see_also.processed.content - n.old.see_also.updated = n.old.content.regex_updated.see_also - n.new.see_also.updated = n.new.see_also.processed - n.match_type + 'V4' - else: - assert (n.old.content.regex_processed.see_also_content == notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content) - n.old.see_also.updated = notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content - n.new.see_also.updated = str_process(n.new.see_also.regex_original.see_also_content) - n.match_type += 'V5' - n.new.content.updated = n.old.content.updated.replace(n.old.see_also.updated, n.new.see_also.updated) - log = Logger(default_filename='SeeAlsoDiff\\__ALL', rm_path=True) - results = [x[0] for x in ankDB().all( - "SELECT DISTINCT target_evernote_guid FROM %s WHERE 1 ORDER BY title ASC " % TABLES.SEE_ALSO)] - changed = 0 - log.banner("UPDATING EVERNOTE SEE ALSO CONTENT", do_print=True) - tmr = stopwatch.Timer(max=len(results), interval=25) - tmr.max = len(results) - for target_evernote_guid in results: - enNote = NotesDB.getEnNoteFromDBByGuid(target_evernote_guid) - n = notes() - if tmr.step(): - print "Note %5s: %s: %s" % ('#' + str(tmr.count), tmr.progress, enNote.FullTitle if enNote.Status.IsSuccess else '(%s)' % target_evernote_guid) - if not enNote.Status.IsSuccess: - log.go("Could not get en note for %s" % target_evernote_guid) - continue - for tag in [EVERNOTE.TAG.TOC, EVERNOTE.TAG.OUTLINE]: - if tag in enNote.TagNames: break - else: - flds = ankDB().scalar("SELECT flds FROM notes WHERE flds LIKE '%%%s%s%%'" % (FIELDS.EVERNOTE_GUID_PREFIX, target_evernote_guid)) - n.new.see_also = notes.version.pstrings(flds.split("\x1f")[FIELDS.SEE_ALSO_FIELDS_ORD]) - process_note() - if n.match_type != 'V1' and str_process(n.old.see_also.updated) == n.new.see_also.updated: continue - print_results() - changed += 1 - enNote.Content = n.new.content.updated - print "Total %d changed out of %d " % (changed, tmr.max) - - -## HOCM/MVP \ No newline at end of file diff --git a/anknotes/constants.py b/anknotes/constants.py index f98123a..ffee426 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -2,155 +2,182 @@ import os PATH = os.path.dirname(os.path.abspath(__file__)) - - +class FOLDERS: + ADDONS = os.path.dirname(PATH) + EXTRA = os.path.join(PATH, 'extra') + ANCILLARY = os.path.join(EXTRA, 'ancillary') + GRAPHICS = os.path.join(EXTRA, 'graphics') + LOGS = os.path.join(EXTRA, 'logs') + DEVELOPER = os.path.join(EXTRA, 'dev') + USER = os.path.join(EXTRA, 'user') + +class FILES: + class LOG: + class FDN: + ANKI_ORPHANS = 'Find Deleted Notes\\' + UNIMPORTED_EVERNOTE_NOTES = ANKI_ORPHANS + 'UnimportedEvernoteNotes' + ANKI_TITLE_MISMATCHES = ANKI_ORPHANS + 'AnkiTitleMismatches' + ANKNOTES_TITLE_MISMATCHES = ANKI_ORPHANS + 'AnknotesTitleMismatches' + ANKNOTES_ORPHANS = ANKI_ORPHANS + 'AnknotesOrphans' + ANKI_ORPHANS += 'AnkiOrphans' + BASE_NAME = '' + DEFAULT_NAME = 'anknotes' + MAIN = DEFAULT_NAME + ACTIVE = DEFAULT_NAME + USE_CALLER_NAME = False + class ANCILLARY: + TEMPLATE = os.path.join(FOLDERS.ANCILLARY, 'FrontTemplate.htm') + CSS = u'_AviAnkiCSS.css' + CSS_QMESSAGEBOX = os.path.join(FOLDERS.ANCILLARY, 'QMessageBox.css') + ENML_DTD = os.path.join(FOLDERS.ANCILLARY, 'enml2.dtd') + class SCRIPTS: + VALIDATION = os.path.join(FOLDERS.ADDONS, 'anknotes_start_note_validation.py') + FIND_DELETED_NOTES = os.path.join(FOLDERS.ADDONS, 'anknotes_start_find_deleted_notes.py') + class GRAPHICS: + class ICON: + EVERNOTE_WEB = os.path.join(FOLDERS.GRAPHICS, u'evernote_web.ico') + EVERNOTE_ARTCORE = os.path.join(FOLDERS.GRAPHICS, u'evernote_artcore.ico') + TOMATO = os.path.join(FOLDERS.GRAPHICS, u'Tomato-icon.ico') + class IMAGE: + pass + IMAGE.EVERNOTE_WEB = ICON.EVERNOTE_WEB.replace('.ico', '.png') + IMAGE.EVERNOTE_ARTCORE = ICON.EVERNOTE_ARTCORE.replace('.ico', '.png') + class USER: + TABLE_OF_CONTENTS_ENEX = os.path.join(FOLDERS.USER, "Table of Contents.enex") + LAST_PROFILE_LOCATION = os.path.join(FOLDERS.USER, 'anki.profile') + class ANKNOTES: - FOLDER_ADDONS_ROOT = os.path.dirname(PATH) - FOLDER_EXTRA = os.path.join(PATH, 'extra') - FOLDER_ANCILLARY = os.path.join(FOLDER_EXTRA, 'ancillary') - FOLDER_GRAPHICS = os.path.join(FOLDER_EXTRA, 'graphics') - FOLDER_LOGS = os.path.join(FOLDER_EXTRA, 'logs') - FOLDER_DEVELOPER = os.path.join(FOLDER_EXTRA, 'dev') - FOLDER_USER = os.path.join(FOLDER_EXTRA, 'user') - LOG_BASE_NAME = '' - LOG_DEFAULT_NAME = 'anknotes' - LOG_MAIN = LOG_DEFAULT_NAME - LOG_ACTIVE = LOG_DEFAULT_NAME - LOG_USE_CALLER_NAME = False - TEMPLATE_FRONT = os.path.join(FOLDER_ANCILLARY, 'FrontTemplate.htm') - CSS = u'_AviAnkiCSS.css' - QT_CSS_QMESSAGEBOX = os.path.join(FOLDER_ANCILLARY, 'QMessageBox.css') - ENML_DTD = os.path.join(FOLDER_ANCILLARY, 'enml2.dtd') - TABLE_OF_CONTENTS_ENEX = os.path.join(FOLDER_USER, "Table of Contents.enex") - VALIDATION_SCRIPT = os.path.join(FOLDER_ADDONS_ROOT, 'anknotes_start_note_validation.py') # anknotes-standAlone.py') - FIND_DELETED_NOTES_SCRIPT = os.path.join(FOLDER_ADDONS_ROOT, 'anknotes_start_find_deleted_notes.py') # anknotes-standAlone.py') - LOG_FDN_ANKI_ORPHANS = 'Find Deleted Notes\\' - LOG_FDN_UNIMPORTED_EVERNOTE_NOTES = LOG_FDN_ANKI_ORPHANS + 'UnimportedEvernoteNotes' - LOG_FDN_ANKI_TITLE_MISMATCHES = LOG_FDN_ANKI_ORPHANS + 'AnkiTitleMismatches' - LOG_FDN_ANKNOTES_TITLE_MISMATCHES = LOG_FDN_ANKI_ORPHANS + 'AnknotesTitleMismatches' - LOG_FDN_ANKNOTES_ORPHANS = LOG_FDN_ANKI_ORPHANS + 'AnknotesOrphans' - LOG_FDN_ANKI_ORPHANS += 'AnkiOrphans' - LAST_PROFILE_LOCATION = os.path.join(FOLDER_USER, 'anki.profile') - ICON_EVERNOTE_WEB = os.path.join(FOLDER_GRAPHICS, u'evernote_web.ico') - IMAGE_EVERNOTE_WEB = ICON_EVERNOTE_WEB.replace('.ico', '.png') - ICON_EVERNOTE_ARTCORE = os.path.join(FOLDER_GRAPHICS, u'evernote_artcore.ico') - ICON_TOMATO = os.path.join(FOLDER_GRAPHICS, u'Tomato-icon.ico') - IMAGE_EVERNOTE_ARTCORE = ICON_EVERNOTE_ARTCORE.replace('.ico', '.png') - EVERNOTE_CONSUMER_KEY = "holycrepe" - EVERNOTE_IS_SANDBOXED = False - DATE_FORMAT = '%Y-%m-%d %H:%M:%S' - DEVELOPER_MODE = (os.path.isfile(os.path.join(FOLDER_DEVELOPER, 'anknotes.developer'))) - DEVELOPER_MODE_AUTOMATE = (os.path.isfile(os.path.join(FOLDER_DEVELOPER, 'anknotes.developer.automate'))) - UPLOAD_AUTO_TOC_NOTES = True # Set False if debugging note creation - AUTO_TOC_NOTES_MAX = -1 # Set to -1 for unlimited - ENABLE_VALIDATION = True - AUTOMATE_VALIDATION = True - ROOT_TITLES_BASE_QUERY = "notebookGuid != 'fdccbccf-ee70-4069-a587-82772a96d9d3'" - NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING = False - IMPORT_MODEL_STYLES_AS_URL = True + DATE_FORMAT = '%Y-%m-%d %H:%M:%S' + DEVELOPER_MODE = (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer'))) + DEVELOPER_MODE_AUTOMATE = (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer.automate'))) + class HIERARCHY: + ROOT_TITLES_BASE_QUERY = "notebookGuid != 'fdccbccf-ee70-4069-a587-82772a96d9d3'" class MODELS: - EVERNOTE_DEFAULT = 'evernote_note' - EVERNOTE_REVERSIBLE = 'evernote_note_reversible' - EVERNOTE_REVERSE_ONLY = 'evernote_note_reverse_only' - EVERNOTE_CLOZE = 'evernote_note_cloze' - TYPE_CLOZE = 1 - + class TYPES: + CLOZE = 1 + class OPTIONS: + IMPORT_STYLES = True + DEFAULT = 'evernote_note' + REVERSIBLE = 'evernote_note_reversible' + REVERSE_ONLY = 'evernote_note_reverse_only' + CLOZE = 'evernote_note_cloze' class TEMPLATES: - EVERNOTE_DEFAULT = 'EvernoteReview' - EVERNOTE_REVERSED = 'EvernoteReviewReversed' - EVERNOTE_CLOZE = 'EvernoteReviewCloze' + DEFAULT = 'EvernoteReview' + REVERSED = 'EvernoteReviewReversed' + CLOZE = 'EvernoteReviewCloze' class FIELDS: - TITLE = 'Title' - CONTENT = 'Content' - SEE_ALSO = 'See_Also' - TOC = 'TOC' - OUTLINE = 'Outline' - EXTRA = 'Extra' - EVERNOTE_GUID = 'Evernote GUID' - UPDATE_SEQUENCE_NUM = 'updateSequenceNum' - EVERNOTE_GUID_PREFIX = 'evernote_guid=' - LIST = [TITLE, CONTENT, SEE_ALSO, EXTRA, TOC, OUTLINE, - UPDATE_SEQUENCE_NUM] - SEE_ALSO_FIELDS_ORD = LIST.index(SEE_ALSO) + 1 - + TITLE = 'Title' + CONTENT = 'Content' + SEE_ALSO = 'See_Also' + TOC = 'TOC' + OUTLINE = 'Outline' + EXTRA = 'Extra' + EVERNOTE_GUID = 'Evernote GUID' + UPDATE_SEQUENCE_NUM = 'updateSequenceNum' + EVERNOTE_GUID_PREFIX = 'evernote_guid=' + LIST = [TITLE, CONTENT, SEE_ALSO, EXTRA, TOC, OUTLINE, + UPDATE_SEQUENCE_NUM] + class ORD: + pass + ORD.CONTENT = LIST.index(CONTENT) + 1 + ORD.SEE_ALSO = LIST.index(SEE_ALSO) + 1 + class DECKS: - DEFAULT = "Evernote" - TOC_SUFFIX = "::See Also::TOC" - OUTLINE_SUFFIX = "::See Also::Outline" - - + DEFAULT = "Evernote" + TOC_SUFFIX = "::See Also::TOC" + OUTLINE_SUFFIX = "::See Also::Outline" + +class ANKI: + PROFILE_NAME = '' + NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING = False + +class TAGS: + TOC = '#TOC' + AUTO_TOC = '#TOC.Auto' + OUTLINE = '#Outline' + OUTLINE_TESTABLE = '#Outline.Testable' + REVERSIBLE = '#Reversible' + REVERSE_ONLY = '#Reversible_Only' + class EVERNOTE: - class TAG: - TOC = '#TOC' - AUTO_TOC = '#TOC.Auto' - OUTLINE = '#Outline' - OUTLINE_TESTABLE = '#Outline.Testable' - REVERSIBLE = '#Reversible' - REVERSE_ONLY = '#Reversible_Only' - - # Note that Evernote's API documentation says not to run API calls to findNoteMetadata with any less than a 15 minute interval - PAGING_RESTART_INTERVAL = 60 * 15 - # Auto Paging is probably only useful in the first 24 hours, when API usage is unlimited, or when executing a search that is likely to have most of the notes up-to-date locally - # To keep from overloading Evernote's servers, and flagging our API key, I recommend pausing 5-15 minutes in between searches, the higher the better. - PAGING_TIMER_INTERVAL = 60 * 15 - PAGING_RESTART_DELAY_MINIMUM_API_CALLS = 10 - PAGING_RESTART_WHEN_COMPLETE = False - IMPORT_TIMER_INTERVAL = PAGING_RESTART_INTERVAL * 2 * 1000 - METADATA_QUERY_LIMIT = 10000 - GET_NOTE_LIMIT = 10000 - - + class IMPORT: + class PAGING: + # Note that Evernote's API documentation says not to run API calls to findNoteMetadata with any less than a 15 minute interval + # Auto Paging is probably only useful in the first 24 hours, when API usage is unlimited, or when executing a search that is likely to have most of the notes up-to-date locally + # To keep from overloading Evernote's servers, and flagging our API key, I recommend pausing 5-15 minutes in between searches, the higher the better. + class RESTART: + DELAY_MINIMUM_API_CALLS = 10 + ENABLED = False + INTERVAL = 60 * 15 + RESTART.INTERVAL = INTERVAL * 2 + INTERVAL = PAGING.INTERVAL * 4 / 3 + METADATA_RESULTS_LIMIT = 10000 + API_CALLS_LIMIT = 300 + class UPLOAD: + ENABLED = True # Set False if debugging note creation + MAX = 25 # Set to -1 for unlimited + RESTART_INTERVAL = 30 # In seconds + class VALIDATION: + ENABLED = True + AUTOMATE = True + class API: + CONSUMER_KEY = "holycrepe" + IS_SANDBOXED = False + class TABLES: - SEE_ALSO = "anknotes_see_also" - MAKE_NOTE_QUEUE = "anknotes_make_note_queue" - - class EVERNOTE: - NOTEBOOKS = "anknotes_evernote_notebooks" - TAGS = "anknotes_evernote_tags" - NOTES = u'anknotes_evernote_notes' - NOTES_HISTORY = u'anknotes_evernote_notes_history' - AUTO_TOC = u'anknotes_evernote_auto_toc' - + SEE_ALSO = "anknotes_see_also" + NOTE_VALIDATION_QUEUE = "anknotes_note_validation_queue" + AUTO_TOC = u'anknotes_auto_toc' + class EVERNOTE: + NOTEBOOKS = "anknotes_evernote_notebooks" + TAGS = "anknotes_evernote_tags" + NOTES = u'anknotes_evernote_notes' + NOTES_HISTORY = u'anknotes_evernote_notes_history' class SETTINGS: - ANKI_PROFILE_NAME = '' - EVERNOTE_LAST_IMPORT = "ankNotesEvernoteLastAutoImport" - ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX = "ankNotesCheckableMenuItems" - KEEP_EVERNOTE_TAGS_DEFAULT_VALUE = True - EVERNOTE_QUERY_TAGS_DEFAULT_VALUE = "#Anki_Import" - DEFAULT_ANKI_DECK_DEFAULT_VALUE = DECKS.DEFAULT - EVERNOTE_ACCOUNT_UID = 'ankNotesEvernoteAccountUID' - EVERNOTE_ACCOUNT_SHARD = 'ankNotesEvernoteAccountSHARD' - EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE = '0' - EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE = 'x999' - EVERNOTE_QUERY_TAGS = 'anknotesEvernoteQueryTags' - EVERNOTE_QUERY_USE_TAGS = 'anknotesEvernoteQueryUseTags' - EVERNOTE_QUERY_EXCLUDED_TAGS = 'anknotesEvernoteQueryExcludedTags' - EVERNOTE_QUERY_USE_EXCLUDED_TAGS = 'anknotesEvernoteQueryUseExcludedTags' - EVERNOTE_QUERY_LAST_UPDATED_VALUE_RELATIVE = 'anknotesEvernoteQueryLastUpdatedValueRelative' - EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_DATE = 'anknotesEvernoteQueryLastUpdatedValueAbsoluteDate' - EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_TIME = 'anknotesEvernoteQueryLastUpdatedValueAbsoluteDateTime' - EVERNOTE_QUERY_LAST_UPDATED_TYPE = 'anknotesEvernoteQueryLastUpdatedType' - EVERNOTE_QUERY_USE_LAST_UPDATED = 'anknotesEvernoteQueryUseLastUpdated' - EVERNOTE_QUERY_NOTEBOOK = 'anknotesEvernoteQueryNotebook' - EVERNOTE_QUERY_NOTEBOOK_DEFAULT_VALUE = 'My Anki Notebook' - EVERNOTE_QUERY_USE_NOTEBOOK = 'anknotesEvernoteQueryUseNotebook' - EVERNOTE_QUERY_NOTE_TITLE = 'anknotesEvernoteQueryNoteTitle' - EVERNOTE_QUERY_USE_NOTE_TITLE = 'anknotesEvernoteQueryUseNoteTitle' - EVERNOTE_QUERY_SEARCH_TERMS = 'anknotesEvernoteQuerySearchTerms' - EVERNOTE_QUERY_USE_SEARCH_TERMS = 'anknotesEvernoteQueryUseSearchTerms' - EVERNOTE_QUERY_ANY = 'anknotesEvernoteQueryAny' - DELETE_EVERNOTE_TAGS_TO_IMPORT = 'anknotesDeleteEvernoteTagsToImport' - UPDATE_EXISTING_NOTES = 'anknotesUpdateExistingNotes' - EVERNOTE_PAGINATION_CURRENT_PAGE = 'anknotesEvernotePaginationCurrentPage' - EVERNOTE_AUTO_PAGING = 'anknotesEvernoteAutoPaging' - EVERNOTE_AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + ANKNOTES.EVERNOTE_CONSUMER_KEY + ( - "_SANDBOX" if ANKNOTES.EVERNOTE_IS_SANDBOXED else "") - KEEP_EVERNOTE_TAGS = 'anknotesKeepEvernoteTags' - EVERNOTE_TAGS_TO_DELETE = 'anknotesEvernoteTagsToDelete' - ANKI_DECK_EVERNOTE_NOTEBOOK_INTEGRATION = 'anknotesUseNotebookNameForAnkiDeckName' - DEFAULT_ANKI_DECK = 'anknotesDefaultAnkiDeck' + class EVERNOTE: + class QUERY: + TAGS_DEFAULT_VALUE = "#Anki_Import" + TAGS = 'anknotesEvernoteQueryTags' + USE_TAGS = 'anknotesEvernoteQueryUseTags' + EXCLUDED_TAGS = 'anknotesEvernoteQueryExcludedTags' + USE_EXCLUDED_TAGS = 'anknotesEvernoteQueryUseExcludedTags' + LAST_UPDATED_VALUE_RELATIVE = 'anknotesEvernoteQueryLastUpdatedValueRelative' + LAST_UPDATED_VALUE_ABSOLUTE_DATE = 'anknotesEvernoteQueryLastUpdatedValueAbsoluteDate' + LAST_UPDATED_VALUE_ABSOLUTE_TIME = 'anknotesEvernoteQueryLastUpdatedValueAbsoluteDateTime' + LAST_UPDATED_TYPE = 'anknotesEvernoteQueryLastUpdatedType' + USE_LAST_UPDATED = 'anknotesEvernoteQueryUseLastUpdated' + NOTEBOOK = 'anknotesEvernoteQueryNotebook' + NOTEBOOK_DEFAULT_VALUE = 'My Anki Notebook' + USE_NOTEBOOK = 'anknotesEvernoteQueryUseNotebook' + NOTE_TITLE = 'anknotesEvernoteQueryNoteTitle' + USE_NOTE_TITLE = 'anknotesEvernoteQueryUseNoteTitle' + SEARCH_TERMS = 'anknotesEvernoteQuerySearchTerms' + USE_SEARCH_TERMS = 'anknotesEvernoteQueryUseSearchTerms' + ANY = 'anknotesEvernoteQueryAny' + class ACCOUNT: + UID = 'ankNotesEvernoteAccountUID' + SHARD = 'ankNotesEvernoteAccountSHARD' + UID_DEFAULT_VALUE = '0' + SHARD_DEFAULT_VALUE = 'x999' + LAST_IMPORT = "ankNotesEvernoteLastAutoImport" + PAGINATION_CURRENT_PAGE = 'anknotesEvernotePaginationCurrentPage' + AUTO_PAGING = 'anknotesEvernoteAutoPaging' + AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + EVERNOTE.API.CONSUMER_KEY + ( + "_SANDBOX" if EVERNOTE.API.IS_SANDBOXED else "") + class ANKI: + class DECKS: + EVERNOTE_NOTEBOOK_INTEGRATION = 'anknotesUseNotebookNameForAnkiDeckName' + BASE = 'anknotesDefaultAnkiDeck' + BASE_DEFAULT_VALUE = DECKS.DEFAULT + UPDATE_EXISTING_NOTES = 'anknotesUpdateExistingNotes' + class TAGS: + TO_DELETE = 'anknotesTagsToDelete' + KEEP_TAGS_DEFAULT_VALUE = True + KEEP_TAGS = 'anknotesTagsKeepEvernoteTags' + DELETE_EVERNOTE_QUERY_TAGS = 'anknotesTagsDeleteEvernoteQueryTags' + ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX = "ankNotesCheckableMenuItems" diff --git a/anknotes/counters.py b/anknotes/counters.py new file mode 100644 index 0000000..49ee936 --- /dev/null +++ b/anknotes/counters.py @@ -0,0 +1,173 @@ +from addict import Dict +import os +from pprint import pprint +absolutely_unused_variable = os.system("cls") + +def print_banner(title): + print "-" * 40 + print title + print "-" * 40 + + +class Counter(Dict): + @staticmethod + def print_banner(title): + print "-" * 40 + print title + print "-" * 40 + + def __init__(self, *args, **kwargs): + self.setCount(0) + return super(Counter, self).__init__(*args, **kwargs) + + def __key_transform__(self, key): + for k in self.keys(): + if k.lower() == key.lower(): return k + return key + # return key[0].upper() + key[1:].lower() + + __count__ = 0 + __is_exclusive_sum__ = False + # @property + # def Count(self): + # return self.__count__ + __my_attrs__ = '__count__|__is_exclusive_sum__' + def getCount(self): + if self.__is_exclusive_sum__: return self.main_count + return self.__count__ + + def setCount(self, value): + self.__is_exclusive_sum__ = False + self.__count__ = value + + @property + def main_count(self): + self.print_banner("Getting main Count ") + sum = 0 + for key in self.iterkeys(): + val = self[key] + if isinstance(val, int): + sum += val + elif isinstance(val, Counter): + if hasattr(val, 'Count'): sum += val.getCount() + + print 'main_count: ' + key + ': - ' + str(val) + ' ~ ' + str(sum) + return sum + + @property + def value(self): + if not hasattr(self, 'Count'): return 0 + return self.getCount() + + + def increment(self, y): + # from copy import deepcopy + self.setCount(self.getCount() + y) + # return copy + + + def __sub__(self, y): + return self.__add__(-1 * y) + print " Want to subtr y " + y + + def __sum__(self): + return 12 + # def __add__(self, y): + # return self.Count + y + + def __setattr__(self, key, value): + key_adj = self.__key_transform__(key) + if key[0:2] + key[-2:] == '____': + if key.lower() not in self.__my_attrs__.lower().split('|'): + raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) + else: super(Dict, self).__setattr__(key, value) + elif key == 'Count': + self.setCount(value) + # super(CaseInsensitiveDict, self).__setattr__(key, value) + # setattr(self, 'Count', value) + elif (hasattr(self, key)): + print "Setting key " + key + ' value... to ' + str(value) + self[key_adj].setCount(value) + else: super(Counter, self).__setitem__(key_adj, value) + + # def __setitem__(self, key, value): + # if key[0:2] + key[-2:] == '____': raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) + # else: super(CaseInsensitiveDict, self).__setitem__(self.__key_transform__(key), value) + + # def __str__(self): + # return str(self.getCount()) + '\n' + super(Dict, self).__str__() + + # def __repr__(self): + # return str(self.getCount()) + '\n' + super(Dict, self).__repr__() + + # def __str_base__(self): + # return super(Dict, self).__str__() + + # def __repr_base__(self): + # return super(Dict, self).__repr__() + + def __repr__(self): + strr = "<%s%d>" % ('*' if self.__is_exclusive_sum__ else '', self.getCount() ) + delimit=': ' + if len(self.keys()) > 0: + strr += delimit + delimit='' + delimit_suffix='' + for key in self.keys(): + val = self[key] + is_recursive = hasattr(val, 'keys') and len(val.keys()) + strr += delimit + delimit_suffix + delimit_suffix='' + if is_recursive: strr += '\n { ' + else: strr += ' ' + strr += "%s: %s" % (key, self[key].__repr__().replace('\n', '\n ')) + if is_recursive: + strr += ' }' + delimit_suffix='\n' + delimit=', ' + strr += "" + return strr + + def __getitem__(self, key): + # print "Getting " + key + adjkey = self.__key_transform__(key) + # if hasattr(self, key.lower()): + # print "I have " + key + + # return super(CaseInsensitiveDict, self).__getitem__(key.lower()) + if key == 'Count': return self.getCount() + if adjkey not in self: + if key[0:2] + key[-2:] == '____': + if key.lower() not in self.__my_attrs__.lower().split('|'): return super(Dict, self).__getattr__(key.lower()) + return super(Counter, self).__getattr__(key.lower()) + # new_dict = CaseInsensitiveDict() + # new_dict.Count = 0 + # print "New dict for " + key + self[adjkey] = Counter() + self[adjkey].__is_exclusive_sum__ = True + return super(Counter, self).__getitem__(adjkey) + + +# Counts = Counter() +# # Counts.Count = 0 +# Counts.Max = 100 +# print "Counts.Max: " + str(Counts ) +# Counts.Current.Updated.setCount(5) +# Counts.Current.Updated.Skipped.setCount(3) +# Counts.Current.Created.setCount(20) +# print "Counts.Current.* " + str(Counts.getCount() ) +# # Counts.max += 1 +# # Counts.Total += 1 +# print "Now, Final Counts: \n" +# print Counts +# # print Counts.New.Skipped + 5 +# print Counts.Current.main_count + + +# print_banner("pprint counts 1") +# pprint( Counts) +# Counts.increment(-7) +# print_banner("pprint counts -= 7") +# pprint( Counts) +# print_banner("pprint counts.current.created.count") +# pprint(Counts.Current.Created.getCount()) \ No newline at end of file diff --git a/anknotes/db.py b/anknotes/db.py index a517b8e..88ac728 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -7,192 +7,200 @@ from anknotes.constants import * try: - from aqt import mw + from aqt import mw except: - pass + pass ankNotesDBInstance = None dbLocal = False def last_anki_profile_name(): - anki_profile_path_root = os.path.abspath(os.path.join(os.path.dirname(PATH), '..' + os.path.sep)) - print anki_profile_path_root - name = SETTINGS.ANKI_PROFILE_NAME - if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name - if os.path.isfile(ANKNOTES.LAST_PROFILE_LOCATION): name = file(ANKNOTES.LAST_PROFILE_LOCATION, 'r').read().strip() - if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name - name = SETTINGS.ANKI_PROFILE_NAME - if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name - dirs = [x for x in os.listdir(anki_profile_path_root) if os.path.isdir(os.path.join(anki_profile_path_root, x)) and x is not 'addons'] - if not dirs: return "" - return dirs[0] + anki_profile_path_root = os.path.abspath(os.path.join(os.path.dirname(PATH), '..' + os.path.sep)) + print anki_profile_path_root + name = ANKI.PROFILE_NAME + if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name + if os.path.isfile(FILES.USER.LAST_PROFILE_LOCATION): name = file(FILES.USER.LAST_PROFILE_LOCATION, 'r').read().strip() + if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name + name = ANKI.PROFILE_NAME + if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name + dirs = [x for x in os.listdir(anki_profile_path_root) if os.path.isdir(os.path.join(anki_profile_path_root, x)) and x is not 'addons'] + if not dirs: return "" + return dirs[0] def ankDBSetLocal(): - global dbLocal - dbLocal = True + global dbLocal + dbLocal = True def ankDBIsLocal(): - global dbLocal - return dbLocal + global dbLocal + return dbLocal def ankDB(reset=False): - global ankNotesDBInstance, dbLocal - if not ankNotesDBInstance or reset: - if dbLocal: - ankNotesDBInstance = ank_DB( os.path.abspath(os.path.join(PATH, '..' + os.path.sep , '..' + os.path.sep , last_anki_profile_name() + os.path.sep, 'collection.anki2'))) - else: - ankNotesDBInstance = ank_DB() - return ankNotesDBInstance + global ankNotesDBInstance, dbLocal + if not ankNotesDBInstance or reset: + if dbLocal: + ankNotesDBInstance = ank_DB( os.path.abspath(os.path.join(PATH, '..' + os.path.sep , '..' + os.path.sep , last_anki_profile_name() + os.path.sep, 'collection.anki2'))) + else: + ankNotesDBInstance = ank_DB() + return ankNotesDBInstance def escape_text_sql(title): - return title.replace("'", "''") + return title.replace("'", "''") def get_evernote_title_from_guid(guid): - return ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, guid)) + return ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, guid)) def get_anki_deck_id_from_note_id(nid): - return long(ankDB().scalar("SELECT did FROM cards WHERE nid = ?", nid)) + return long(ankDB().scalar("SELECT did FROM cards WHERE nid = ?", nid)) def get_evernote_guid_from_anki_fields(fields): - if not FIELDS.EVERNOTE_GUID in fields: return None - return fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') + if not FIELDS.EVERNOTE_GUID in fields: return None + return fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') def get_all_local_db_guids(): - return [x[0] for x in ankDB().all("SELECT guid FROM %s WHERE 1" % TABLES.EVERNOTE.NOTES)] + return [x[0] for x in ankDB().all("SELECT guid FROM %s WHERE 1 ORDER BY title ASC" % TABLES.EVERNOTE.NOTES)] class ank_DB(object): - def __init__(self, path=None, text=None, timeout=0): - encpath = path - if isinstance(encpath, unicode): - encpath = path.encode("utf-8") - if path: - self._db = sqlite.connect(encpath, timeout=timeout) - self._db.row_factory = sqlite.Row - if text: - self._db.text_factory = text - self._path = path - else: - self._db = mw.col.db._db - self._path = mw.col.db._path - self._db.row_factory = sqlite.Row - self.echo = os.environ.get("DBECHO") - self.mod = False - - def setrowfactory(self): - self._db.row_factory = sqlite.Row - - def execute(self, sql, *a, **ka): - s = sql.strip().lower() - # mark modified? - for stmt in "insert", "update", "delete": - if s.startswith(stmt): - self.mod = True - t = time.time() - if ka: - # execute("...where id = :id", id=5) - res = self._db.execute(sql, ka) - elif a: - # execute("...where id = ?", 5) - res = self._db.execute(sql, a) - else: - res = self._db.execute(sql) - if self.echo: - # print a, ka - print sql, "%0.3fms" % ((time.time() - t) * 1000) - if self.echo == "2": - print a, ka - return res - - def executemany(self, sql, l): - self.mod = True - t = time.time() - self._db.executemany(sql, l) - if self.echo: - print sql, "%0.3fms" % ((time.time() - t) * 1000) - if self.echo == "2": - print l - - def commit(self): - t = time.time() - self._db.commit() - if self.echo: - print "commit %0.3fms" % ((time.time() - t) * 1000) - - def executescript(self, sql): - self.mod = True - if self.echo: - print sql - self._db.executescript(sql) - - def rollback(self): - self._db.rollback() - - def scalar(self, sql, *a, **kw): - res = self.execute(sql, *a, **kw).fetchone() - if res: - return res[0] - return None - - def all(self, sql, *a, **kw): - return self.execute(sql, *a, **kw).fetchall() - - def first(self, sql, *a, **kw): - c = self.execute(sql, *a, **kw) - res = c.fetchone() - c.close() - return res - - def list(self, sql, *a, **kw): - return [x[0] for x in self.execute(sql, *a, **kw)] - - def close(self): - self._db.close() - - def set_progress_handler(self, *args): - self._db.set_progress_handler(*args) - - def __enter__(self): - self._db.execute("begin") - return self - - def __exit__(self, exc_type, *args): - self._db.close() - - def totalChanges(self): - return self._db.total_changes - - def interrupt(self): - self._db.interrupt() - - def InitTags(self, force=False): - if_exists = " IF NOT EXISTS" if not force else "" - self.execute( - """CREATE TABLE %s `%s` ( `guid` TEXT NOT NULL UNIQUE, `name` TEXT NOT NULL, `parentGuid` TEXT, `updateSequenceNum` INTEGER NOT NULL, PRIMARY KEY(guid) );""" % ( - if_exists, TABLES.EVERNOTE.TAGS)) - - def InitNotebooks(self, force=False): - if_exists = " IF NOT EXISTS" if not force else "" - self.execute( - """CREATE TABLE %s `%s` ( `guid` TEXT NOT NULL UNIQUE, `name` TEXT NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `serviceUpdated` INTEGER NOT NULL, `stack` TEXT, PRIMARY KEY(guid) );""" % ( - if_exists, TABLES.EVERNOTE.NOTEBOOKS)) - - def Init(self): - self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL UNIQUE, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL, PRIMARY KEY(guid) );""" % TABLES.EVERNOTE.NOTES) - self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL)""" % TABLES.EVERNOTE.NOTES_HISTORY) - self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `id` INTEGER, `source_evernote_guid` TEXT NOT NULL, `number` INTEGER NOT NULL DEFAULT 100, `uid` INTEGER NOT NULL DEFAULT -1, `shard` TEXT NOT NULL DEFAULT -1, `target_evernote_guid` TEXT NOT NULL, `html` TEXT NOT NULL, `title` TEXT NOT NULL, `from_toc` INTEGER DEFAULT 0, `is_toc` INTEGER DEFAULT 0, `is_outline` INTEGER DEFAULT 0, PRIMARY KEY(id) );""" % TABLES.SEE_ALSO) - self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `root_title` TEXT NOT NULL UNIQUE, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL, `notebookGuid` TEXT NOT NULL, PRIMARY KEY(root_title) );""" % TABLES.EVERNOTE.AUTO_TOC) - self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT, `title` TEXT NOT NULL, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL DEFAULT ',,', `notebookGuid` TEXT, `validation_status` INTEGER NOT NULL DEFAULT 0, `validation_result` TEXT);""" % TABLES.MAKE_NOTE_QUEUE) - self.InitTags() - self.InitNotebooks() + def __init__(self, path=None, text=None, timeout=0): + encpath = path + if isinstance(encpath, unicode): + encpath = path.encode("utf-8") + if path: + self._db = sqlite.connect(encpath, timeout=timeout) + self._db.row_factory = sqlite.Row + if text: + self._db.text_factory = text + self._path = path + else: + self._db = mw.col.db._db + self._path = mw.col.db._path + self._db.row_factory = sqlite.Row + self.echo = os.environ.get("DBECHO") + self.mod = False + + def setrowfactory(self): + self._db.row_factory = sqlite.Row + + def execute(self, sql, *a, **ka): + s = sql.strip().lower() + # mark modified? + for stmt in "insert", "update", "delete": + if s.startswith(stmt): + self.mod = True + t = time.time() + if ka: + # execute("...where id = :id", id=5) + res = self._db.execute(sql, ka) + elif a: + # execute("...where id = ?", 5) + res = self._db.execute(sql, a) + else: + res = self._db.execute(sql) + if self.echo: + # print a, ka + print sql, "%0.3fms" % ((time.time() - t) * 1000) + if self.echo == "2": + print a, ka + return res + + def executemany(self, sql, l): + self.mod = True + t = time.time() + self._db.executemany(sql, l) + if self.echo: + print sql, "%0.3fms" % ((time.time() - t) * 1000) + if self.echo == "2": + print l + + def commit(self): + t = time.time() + self._db.commit() + if self.echo: + print "commit %0.3fms" % ((time.time() - t) * 1000) + + def executescript(self, sql): + self.mod = True + if self.echo: + print sql + self._db.executescript(sql) + + def rollback(self): + self._db.rollback() + + def scalar(self, sql, *a, **kw): + res = self.execute(sql, *a, **kw).fetchone() + if res: + return res[0] + return None + + def all(self, sql, *a, **kw): + return self.execute(sql, *a, **kw).fetchall() + + def first(self, sql, *a, **kw): + c = self.execute(sql, *a, **kw) + res = c.fetchone() + c.close() + return res + + def list(self, sql, *a, **kw): + return [x[0] for x in self.execute(sql, *a, **kw)] + + def close(self): + self._db.close() + + def set_progress_handler(self, *args): + self._db.set_progress_handler(*args) + + def __enter__(self): + self._db.execute("begin") + return self + + def __exit__(self, exc_type, *args): + self._db.close() + + def totalChanges(self): + return self._db.total_changes + + def interrupt(self): + self._db.interrupt() + + def InitTags(self, force=False): + if_exists = " IF NOT EXISTS" if not force else "" + self.execute( + """CREATE TABLE %s `%s` ( `guid` TEXT NOT NULL UNIQUE, `name` TEXT NOT NULL, `parentGuid` TEXT, `updateSequenceNum` INTEGER NOT NULL, PRIMARY KEY(guid) );""" % ( + if_exists, TABLES.EVERNOTE.TAGS)) + + def InitNotebooks(self, force=False): + if_exists = " IF NOT EXISTS" if not force else "" + self.execute( + """CREATE TABLE %s `%s` ( `guid` TEXT NOT NULL UNIQUE, `name` TEXT NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `serviceUpdated` INTEGER NOT NULL, `stack` TEXT, PRIMARY KEY(guid) );""" % ( + if_exists, TABLES.EVERNOTE.NOTEBOOKS)) + + def InitSeeAlso(self, forceRebuild=False): + if_exists = "IF NOT EXISTS" + if forceRebuild: + self.execute("DROP TABLE %s " % TABLES.SEE_ALSO) + self.commit() + if_exists = "" + self.execute( + """CREATE TABLE %s `%s` ( `id` INTEGER, `source_evernote_guid` TEXT NOT NULL, `number` INTEGER NOT NULL DEFAULT 100, `uid` INTEGER NOT NULL DEFAULT -1, `shard` TEXT NOT NULL DEFAULT -1, `target_evernote_guid` TEXT NOT NULL, `html` TEXT NOT NULL, `title` TEXT NOT NULL, `from_toc` INTEGER DEFAULT 0, `is_toc` INTEGER DEFAULT 0, `is_outline` INTEGER DEFAULT 0, PRIMARY KEY(id) );""" % (if_exists, TABLES.SEE_ALSO)) + + def Init(self): + self.execute( + """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL UNIQUE, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL, PRIMARY KEY(guid) );""" % TABLES.EVERNOTE.NOTES) + self.execute( + """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL)""" % TABLES.EVERNOTE.NOTES_HISTORY) + self.execute( + """CREATE TABLE IF NOT EXISTS `%s` ( `root_title` TEXT NOT NULL UNIQUE, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL, `notebookGuid` TEXT NOT NULL, PRIMARY KEY(root_title) );""" % TABLES.AUTO_TOC) + self.execute( + """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT, `title` TEXT NOT NULL, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL DEFAULT ',,', `notebookGuid` TEXT, `validation_status` INTEGER NOT NULL DEFAULT 0, `validation_result` TEXT);""" % TABLES.NOTE_VALIDATION_QUEUE) + self.InitSeeAlso() + self.InitTags() + self.InitNotebooks() diff --git a/anknotes/detect_see_also_changes.py b/anknotes/detect_see_also_changes.py new file mode 100644 index 0000000..7c6a9ca --- /dev/null +++ b/anknotes/detect_see_also_changes.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +import shutil + +try: + from pysqlite2 import dbapi2 as sqlite +except ImportError: + from sqlite3 import dbapi2 as sqlite + +from anknotes.shared import * +from anknotes import stopwatch +# from anknotes.stopwatch import clockit +import re +from anknotes._re import __Match + +from anknotes.EvernoteNotePrototype import EvernoteNotePrototype +from anknotes.AnkiNotePrototype import AnkiNotePrototype +from enum import Enum +from anknotes.enums import * +from anknotes.structs import EvernoteAPIStatus + +Error = sqlite.Error +ankDBSetLocal() +from anknotes.ankEvernote import Evernote +from anknotes.Anki import Anki + +class notes: + class version(object): + class pstrings: + __updated__ = None + __processed__ = None + __original__ = None + __regex_updated__ = None + """: type : notes.version.see_also_match """ + __regex_processed__ = None + """: type : notes.version.see_also_match """ + __regex_original__ = None + """: type : notes.version.see_also_match """ + + @property + def regex_original(self): + if self.original is None: return None + if self.__regex_original__ is None: + self.__regex_original__ = notes.version.see_also_match(self.original) + return self.__regex_original__ + + @property + def regex_processed(self): + if self.processed is None: return None + if self.__regex_processed__ is None: + self.__regex_processed__ = notes.version.see_also_match(self.processed) + return self.__regex_processed__ + + @property + def regex_updated(self): + if self.updated is None: return None + if self.__regex_updated__ is None: + self.__regex_updated__ = notes.version.see_also_match(self.updated) + return self.__regex_updated__ + + @property + def processed(self): + if self.__processed__ is None: + self.__processed__ = str_process(self.original) + return self.__processed__ + + @property + def updated(self): + if self.__updated__ is None: return str_process(self.__original__) + return self.__updated__ + + @updated.setter + def updated(self, value): + self.__regex_updated__ = None + self.__updated__ = value + + @property + def final(self): + return str_process_full(self.updated) + + @property + def original(self): + return self.__original__ + + def useProcessed(self): + self.updated = self.processed + + def __init__(self, original=None): + self.__original__ = original + + class see_also_match(object): + __subject__ = None + __content__ = None + __matchobject__ = None + """:type : __Match """ + __match_attempted__ = 0 + + @property + def subject(self): + if not self.__subject__: return self.content + return self.__subject__ + + @subject.setter + def subject(self, value): + self.__subject__ = value + self.__match_attempted__ = 0 + self.__matchobject__ = None + + @property + def content(self): + return self.__content__ + + def groups(self, group=0): + """ + :param group: + :type group : int | str | unicode + :return: + """ + if not self.successful_match: + return None + return self.__matchobject__.group(group) + + @property + def successful_match(self): + if self.__matchobject__: return True + if self.__match_attempted__ is 0 and self.subject is not None: + self.__matchobject__ = notes.rgx.search(self.subject) + """:type : __Match """ + self.__match_attempted__ += 1 + return self.__matchobject__ is not None + + @property + def main(self): + return self.groups(0) + + @property + def see_also(self): + return self.groups('SeeAlso') + + @property + def see_also_content(self): + return self.groups('SeeAlsoContent') + + def __init__(self, content=None): + """ + + :type content: str | unicode + """ + self.__content__ = content + self.__match_attempted__ = 0 + self.__matchobject__ = None + """:type : __Match """ + content = pstrings() + see_also = pstrings() + old = version() + new = version() + rgx = regex_see_also() + match_type = 'NA' + + +def str_process(strr): + if not strr: return strr + strr = strr.replace(u"evernote:///", u"evernote://") + strr = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', + r'evernote://view/\2/\1/\3/\3/', strr) + strr = strr.replace(u"evernote://", u"evernote:///").replace(u'<BR>', u'<br />') + strr = re.sub(r'<br ?/?>', u'<br/>', strr, 0, re.IGNORECASE) + strr = re.sub(r'(?s)<<(?P<PrefixKeep>(?:</div>)?)<div class="occluded">(?P<OccludedText>.+?)</div>>>', r'<<\g<PrefixKeep>>>', strr) + strr = strr.replace('<span class="occluded">', '<span style="color: rgb(255, 255, 255);">') + return strr + +def str_process_full(strr): + return clean_evernote_css(strr) + +def main(evernote=None, anki=None): + # @clockit + def print_results(log_folder='Diff\\SeeAlso',full=False, final=False): + if final: + oldResults=n.old.content.final + newResults=n.new.content.final + elif full: + oldResults=n.old.content.updated + newResults=n.new.content.updated + else: + oldResults=n.old.see_also.updated + newResults=n.new.see_also.updated + diff = generate_diff(oldResults, newResults) + log.plain(diff, log_folder+'\\Diff\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) + log.plain(diffify(oldResults,split=False), log_folder+'\\Original\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) + log.plain(diffify(newResults,split=False), log_folder+'\\New\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) + if final: + log.plain(oldResults, log_folder+'\\Final\\Old\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) + log.plain(newResults, log_folder+'\\Final\\New\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) + log.plain(diff + '\n', log_folder+'\\__All') + + # @clockit + def process_note(): + n.old.content = notes.version.pstrings(enNote.Content) + if not n.old.content.regex_original.successful_match: + if n.new.see_also.original == "": + n.new.content = notes.version.pstrings(n.old.content.original) + return False + n.new.content = notes.version.pstrings(n.old.content.original.replace('</en-note>', '<div><span><br/></span></div>' + n.new.see_also.original + '\n</en-note>')) + n.new.see_also.updated = str_process(n.new.content.original) + n.old.see_also.updated = str_process(n.old.content.original) + log.plain(enNote.Guid + '<BR>' + ', '.join(enNote.TagNames) + '<HR>' + enNote.Content + '<HR>' + n.new.see_also.updated, 'SeeAlsoMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) + n.match_type = 'V1' + else: + n.old.see_also = notes.version.pstrings(n.old.content.regex_original.main) + n.match_type = 'V2' + if n.old.see_also.regex_processed.successful_match: + assert True or str_process(n.old.content.regex_original.main) is n.old.content.regex_processed.main + n.old.content.updated = n.old.content.original.replace(n.old.content.regex_original.main, str_process(n.old.content.regex_original.main)) + n.old.see_also.useProcessed() + n.match_type += 'V3' + n.new.see_also.regex_original.subject = n.new.see_also.original + '</en-note>' + if not n.new.see_also.regex_original.successful_match: + log.plain(enNote.Guid + '\n' + ', '.join(enNote.TagNames) + '\n' + n.new.see_also.original.content, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) + see_also_replace_old = n.old.content.original.match.processed.see_also.processed.content + n.old.see_also.updated = n.old.content.regex_updated.see_also + n.new.see_also.updated = n.new.see_also.processed + n.match_type + 'V4' + else: + assert (n.old.content.regex_processed.see_also_content == notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content) + n.old.see_also.updated = notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content + n.new.see_also.updated = str_process(n.new.see_also.regex_original.see_also_content) + n.match_type += 'V5' + n.new.content.updated = n.old.content.updated.replace(n.old.see_also.updated, n.new.see_also.updated) + log = Logger(default_filename='SeeAlsoDiff\\__ALL', rm_path=True) + # SELECT DISTINCT s.target_evernote_guid FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid ORDER BY n.title ASC + # SELECT DISTINCT s.target_evernote_guid, n.* FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid ORDER BY n.title ASC; + # SELECT DISTINCT s.target_evernote_guid, n.* FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid AND n.tagNames NOT LIKE '%,#TOC,%' AND n.tagNames NOT LIKE '%,#Outline,%' ORDER BY n.title ASC; + sql = "SELECT DISTINCT s.target_evernote_guid, n.* FROM %s as s, %s as n WHERE s.target_evernote_guid = n.guid AND n.tagNames NOT LIKE '%%,%s,%%' AND n.tagNames NOT LIKE '%%,%s,%%' ORDER BY n.title ASC;" + results = ankDB().all(sql % (TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES, TAGS.TOC, TAGS.OUTLINE)) + count_queued = 0 + tmr = stopwatch.Timer(len(results), 25, label='SeeAlso-Step7') + log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), do_print=True) + log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), tmr.label) + notes_updated=[] + number_updated = 0 + for result in results: + enNote = EvernoteNotePrototype(db_note=result) + n = notes() + if tmr.step(): + log.go("Note %5s: %s: %s" % ('#' + str(tmr.count), tmr.progress, enNote.FullTitle if enNote.Status.IsSuccess else '(%s)' % enNote.Guid), , do_print=True, print_timestamp=False) + flds = ankDB().scalar("SELECT flds FROM notes WHERE flds LIKE '%%%s%s%%'" % (FIELDS.EVERNOTE_GUID_PREFIX, enNote.Guid)).split("\x1f") + n.new.see_also = notes.version.pstrings(flds[FIELDS.ORD.SEE_ALSO]) + result = process_note() + if result is False: + log.go('No match for %s' % enNote.FullTitle, tmr.label + '-NoUpdate') + print_results('NoMatch\\SeeAlso') + print_results('NoMatch\\Contents', full=True) + continue + if n.match_type != 'V1' and str_process(n.old.see_also.updated) == n.new.see_also.updated: + log.go('Match but contents are the same for %s' % enNote.FullTitle, tmr.label + '-NoUpdate') + print_results('Same\\SeeAlso') + print_results('Same\\Contents', full=True) + continue + print_results() + print_results('Diff\\Contents', final=True) + enNote.Content = n.new.content.final + if not evernote: evernote = Evernote() + status, whole_note = evernote.makeNote(enNote=enNote) + if tmr.reportStatus(status) == False: raise ValueError + if status.IsDelayableError: break + if status.IsSuccess: notes_updated.append(EvernoteNotePrototype(whole_note=whole_note)) + if tmr.count_success > 0: + if not anki: anki = Anki() + number_updated = anki.update_evernote_notes(notes_updated) + log.go("Total %d of %d note(s) successfully uploaded to Evernote" % (tmr.count_success, tmr.max), tmr.label, do_print=True) + if number_updated > 0: log.go(" > %4d updated in Anki" % number_updated, tmr.label, do_print=True) + if tmr.count_queued > 0: log.go(" > %4d queued for validation" % tmr.count_queued, tmr.label, do_print=True) + if tmr.count_error > 0: log.go(" > %4d error(s) occurred" % tmr.count_error, tmr.label, do_print=True) + + +## HOCM/MVP \ No newline at end of file diff --git a/anknotes/enums.py b/anknotes/enums.py index 8dc5642..6fd660e 100644 --- a/anknotes/enums.py +++ b/anknotes/enums.py @@ -1,79 +1,79 @@ from anknotes.enum import Enum, EnumMeta, IntEnum from anknotes import enum class AutoNumber(Enum): - def __new__(cls, *args): - """ + def __new__(cls, *args): + """ - :param cls: - :return: - :rtype : AutoNumber - """ - value = len(cls.__members__) + 1 - if args and args[0]: value=args[0] - while value in cls._value2member_map_: value += 1 - obj = object.__new__(cls) - obj._id_ = value - obj._value_ = value - # if obj.name in obj._member_names_: - # raise KeyError - return obj + :param cls: + :return: + :rtype : AutoNumber + """ + value = len(cls.__members__) + 1 + if args and args[0]: value=args[0] + while value in cls._value2member_map_: value += 1 + obj = object.__new__(cls) + obj._id_ = value + obj._value_ = value + # if obj.name in obj._member_names_: + # raise KeyError + return obj class OrderedEnum(Enum): - def __ge__(self, other): - if self.__class__ is other.__class__: - return self._value_ >= other._value_ - return NotImplemented - def __gt__(self, other): - if self.__class__ is other.__class__: - return self._value_ > other._value_ - return NotImplemented - def __le__(self, other): - if self.__class__ is other.__class__: - return self._value_ <= other._value_ - return NotImplemented - def __lt__(self, other): - if self.__class__ is other.__class__: - return self._value_ < other._value_ - return NotImplemented + def __ge__(self, other): + if self.__class__ is other.__class__: + return self._value_ >= other._value_ + return NotImplemented + def __gt__(self, other): + if self.__class__ is other.__class__: + return self._value_ > other._value_ + return NotImplemented + def __le__(self, other): + if self.__class__ is other.__class__: + return self._value_ <= other._value_ + return NotImplemented + def __lt__(self, other): + if self.__class__ is other.__class__: + return self._value_ < other._value_ + return NotImplemented class auto_enum(EnumMeta): - def __new__(metacls, cls, bases, classdict): - original_dict = classdict - classdict = enum._EnumDict() - for k, v in original_dict.items(): - classdict[k] = v - temp = type(classdict)() - names = set(classdict._member_names) - i = 0 + def __new__(metacls, cls, bases, classdict): + original_dict = classdict + classdict = enum._EnumDict() + for k, v in original_dict.items(): + classdict[k] = v + temp = type(classdict)() + names = set(classdict._member_names) + i = 0 - for k in classdict._member_names: - v = classdict[k] - if v == () : - v = i - else: - i = max(v, i) - i += 1 - temp[k] = v - for k, v in classdict.items(): - if k not in names: - temp[k] = v - return super(auto_enum, metacls).__new__( - metacls, cls, bases, temp) + for k in classdict._member_names: + v = classdict[k] + if v == () : + v = i + else: + i = max(v, i) + i += 1 + temp[k] = v + for k, v in classdict.items(): + if k not in names: + temp[k] = v + return super(auto_enum, metacls).__new__( + metacls, cls, bases, temp) - def __ge__(self, other): - if self.__class__ is other.__class__: - return self._value_ >= other._value_ - return NotImplemented - def __gt__(self, other): - if self.__class__ is other.__class__: - return self._value_ > other._value_ - return NotImplemented - def __le__(self, other): - if self.__class__ is other.__class__: - return self._value_ <= other._value_ - return NotImplemented - def __lt__(self, other): - if self.__class__ is other.__class__: - return self._value_ < other._value_ - return NotImplemented + def __ge__(self, other): + if self.__class__ is other.__class__: + return self._value_ >= other._value_ + return NotImplemented + def __gt__(self, other): + if self.__class__ is other.__class__: + return self._value_ > other._value_ + return NotImplemented + def __le__(self, other): + if self.__class__ is other.__class__: + return self._value_ <= other._value_ + return NotImplemented + def __lt__(self, other): + if self.__class__ is other.__class__: + return self._value_ < other._value_ + return NotImplemented AutoNumberedEnum = auto_enum('AutoNumberedEnum', (OrderedEnum,), {}) diff --git a/anknotes/error.py b/anknotes/error.py index 059a9cc..7dc86a3 100644 --- a/anknotes/error.py +++ b/anknotes/error.py @@ -4,7 +4,7 @@ class RateLimitErrorHandling: - IgnoreError, ToolTipError, AlertError = range(3) + IgnoreError, ToolTipError, AlertError = range(3) EDAM_RATE_LIMIT_ERROR_HANDLING = RateLimitErrorHandling.ToolTipError @@ -14,49 +14,49 @@ class RateLimitErrorHandling: def HandleSocketError(e, strErrorBase): - global latestSocketError - errorcode = e[0] - friendly_error_msgs = { - errno.ECONNREFUSED: "Connection was refused", - errno.WSAECONNRESET: "Connection was reset or forcibly closed by the remote host", - errno.ETIMEDOUT: "Connection timed out" - } - if errorcode not in errno.errorcode: - log_error("Unknown socket error (%s) occurred: %s" % (str(errorcode), str(e))) - return False - error_constant = errno.errorcode[errorcode] - if errorcode in friendly_error_msgs: - strError = friendly_error_msgs[errorcode] - else: - strError = "Unhandled socket error (%s) occurred" % error_constant - latestSocketError = {'code': errorcode, 'friendly_error_msg': strError, 'constant': error_constant} - strError = "Error: %s while %s\r\n" % (strError, strErrorBase) - log_error(" SocketError.%s: " % error_constant + strError) - log_error(str(e)) - log(" SocketError.%s: " % error_constant + strError, 'api') - if EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.AlertError: - showInfo(strError) - elif EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.ToolTipError: - show_tooltip(strError) - return True + global latestSocketError + errorcode = e[0] + friendly_error_msgs = { + errno.ECONNREFUSED: "Connection was refused", + errno.WSAECONNRESET: "Connection was reset or forcibly closed by the remote host", + errno.ETIMEDOUT: "Connection timed out" + } + if errorcode not in errno.errorcode: + log_error("Unknown socket error (%s) occurred: %s" % (str(errorcode), str(e))) + return False + error_constant = errno.errorcode[errorcode] + if errorcode in friendly_error_msgs: + strError = friendly_error_msgs[errorcode] + else: + strError = "Unhandled socket error (%s) occurred" % error_constant + latestSocketError = {'code': errorcode, 'friendly_error_msg': strError, 'constant': error_constant} + strError = "Error: %s while %s\r\n" % (strError, strErrorBase) + log_error(" SocketError.%s: " % error_constant + strError) + log_error(str(e)) + log(" SocketError.%s: " % error_constant + strError, 'api') + if EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.AlertError: + showInfo(strError) + elif EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.ToolTipError: + show_tooltip(strError) + return True latestEDAMRateLimit = 0 def HandleEDAMRateLimitError(e, strError): - global latestEDAMRateLimit - if not e.errorCode is EDAMErrorCode.RATE_LIMIT_REACHED: - return False - latestEDAMRateLimit = e.rateLimitDuration - m, s = divmod(e.rateLimitDuration, 60) - strError = "Error: Rate limit has been reached while %s\r\n" % strError - strError += "Please retry your request in {} min".format("%d:%02d" % (m, s)) - log_strError = " EDAMErrorCode.RATE_LIMIT_REACHED: " + strError.replace('\r\n', '\n') - log_error(log_strError) - log(log_strError, 'api') - if EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.AlertError: - showInfo(strError) - elif EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.ToolTipError: - show_tooltip(strError) - return True + global latestEDAMRateLimit + if not e.errorCode is EDAMErrorCode.RATE_LIMIT_REACHED: + return False + latestEDAMRateLimit = e.rateLimitDuration + m, s = divmod(e.rateLimitDuration, 60) + strError = "Error: Rate limit has been reached while %s\r\n" % strError + strError += "Please retry your request in {} min".format("%d:%02d" % (m, s)) + log_strError = " EDAMErrorCode.RATE_LIMIT_REACHED: " + strError.replace('\r\n', '\n') + log_error(log_strError) + log(log_strError, 'api') + if EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.AlertError: + showInfo(strError) + elif EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.ToolTipError: + show_tooltip(strError) + return True diff --git a/anknotes/extra/ancillary/enml2.dtd b/anknotes/extra/ancillary/enml2.dtd index 8419976..e92e0a8 100644 --- a/anknotes/extra/ancillary/enml2.dtd +++ b/anknotes/extra/ancillary/enml2.dtd @@ -23,19 +23,486 @@ <!ENTITY % HTMLlat1 PUBLIC "-//W3C//ENTITIES Latin 1 for XHTML//EN" - "http://www.w3.org/TR/xhtml1/DTD/xhtml-lat1.ent"> + "xhtml-lat1.ent"> %HTMLlat1; <!ENTITY % HTMLsymbol PUBLIC "-//W3C//ENTITIES Symbols for XHTML//EN" - "http://www.w3.org/TR/xhtml1/DTD/xhtml-symbol.ent"> + "xhtml-symbol.ent"> %HTMLsymbol; <!ENTITY % HTMLspecial PUBLIC "-//W3C//ENTITIES Special for XHTML//EN" - "http://www.w3.org/TR/xhtml1/DTD/xhtml-special.ent"> + "xhtml-special.ent"> %HTMLspecial; +<!--=================== Manually Loaded External character mnemonic entities: Latin 1 ===============================--> +<!ENTITY nbsp " "> <!-- no-break space = non-breaking space, + U+00A0 ISOnum --> +<!ENTITY iexcl "¡"> <!-- inverted exclamation mark, U+00A1 ISOnum --> +<!ENTITY cent "¢"> <!-- cent sign, U+00A2 ISOnum --> +<!ENTITY pound "£"> <!-- pound sign, U+00A3 ISOnum --> +<!ENTITY curren "¤"> <!-- currency sign, U+00A4 ISOnum --> +<!ENTITY yen "¥"> <!-- yen sign = yuan sign, U+00A5 ISOnum --> +<!ENTITY brvbar "¦"> <!-- broken bar = broken vertical bar, + U+00A6 ISOnum --> +<!ENTITY sect "§"> <!-- section sign, U+00A7 ISOnum --> +<!ENTITY uml "¨"> <!-- diaeresis = spacing diaeresis, + U+00A8 ISOdia --> +<!ENTITY copy "©"> <!-- copyright sign, U+00A9 ISOnum --> +<!ENTITY ordf "ª"> <!-- feminine ordinal indicator, U+00AA ISOnum --> +<!ENTITY laquo "«"> <!-- left-pointing double angle quotation mark + = left pointing guillemet, U+00AB ISOnum --> +<!ENTITY not "¬"> <!-- not sign = angled dash, + U+00AC ISOnum --> +<!ENTITY shy "­"> <!-- soft hyphen = discretionary hyphen, + U+00AD ISOnum --> +<!ENTITY reg "®"> <!-- registered sign = registered trade mark sign, + U+00AE ISOnum --> +<!ENTITY macr "¯"> <!-- macron = spacing macron = overline + = APL overbar, U+00AF ISOdia --> +<!ENTITY deg "°"> <!-- degree sign, U+00B0 ISOnum --> +<!ENTITY plusmn "±"> <!-- plus-minus sign = plus-or-minus sign, + U+00B1 ISOnum --> +<!ENTITY sup2 "²"> <!-- superscript two = superscript digit two + = squared, U+00B2 ISOnum --> +<!ENTITY sup3 "³"> <!-- superscript three = superscript digit three + = cubed, U+00B3 ISOnum --> +<!ENTITY acute "´"> <!-- acute accent = spacing acute, + U+00B4 ISOdia --> +<!ENTITY micro "µ"> <!-- micro sign, U+00B5 ISOnum --> +<!ENTITY para "¶"> <!-- pilcrow sign = paragraph sign, + U+00B6 ISOnum --> +<!ENTITY middot "·"> <!-- middle dot = Georgian comma + = Greek middle dot, U+00B7 ISOnum --> +<!ENTITY cedil "¸"> <!-- cedilla = spacing cedilla, U+00B8 ISOdia --> +<!ENTITY sup1 "¹"> <!-- superscript one = superscript digit one, + U+00B9 ISOnum --> +<!ENTITY ordm "º"> <!-- masculine ordinal indicator, + U+00BA ISOnum --> +<!ENTITY raquo "»"> <!-- right-pointing double angle quotation mark + = right pointing guillemet, U+00BB ISOnum --> +<!ENTITY frac14 "¼"> <!-- vulgar fraction one quarter + = fraction one quarter, U+00BC ISOnum --> +<!ENTITY frac12 "½"> <!-- vulgar fraction one half + = fraction one half, U+00BD ISOnum --> +<!ENTITY frac34 "¾"> <!-- vulgar fraction three quarters + = fraction three quarters, U+00BE ISOnum --> +<!ENTITY iquest "¿"> <!-- inverted question mark + = turned question mark, U+00BF ISOnum --> +<!ENTITY Agrave "À"> <!-- latin capital letter A with grave + = latin capital letter A grave, + U+00C0 ISOlat1 --> +<!ENTITY Aacute "Á"> <!-- latin capital letter A with acute, + U+00C1 ISOlat1 --> +<!ENTITY Acirc "Â"> <!-- latin capital letter A with circumflex, + U+00C2 ISOlat1 --> +<!ENTITY Atilde "Ã"> <!-- latin capital letter A with tilde, + U+00C3 ISOlat1 --> +<!ENTITY Auml "Ä"> <!-- latin capital letter A with diaeresis, + U+00C4 ISOlat1 --> +<!ENTITY Aring "Å"> <!-- latin capital letter A with ring above + = latin capital letter A ring, + U+00C5 ISOlat1 --> +<!ENTITY AElig "Æ"> <!-- latin capital letter AE + = latin capital ligature AE, + U+00C6 ISOlat1 --> +<!ENTITY Ccedil "Ç"> <!-- latin capital letter C with cedilla, + U+00C7 ISOlat1 --> +<!ENTITY Egrave "È"> <!-- latin capital letter E with grave, + U+00C8 ISOlat1 --> +<!ENTITY Eacute "É"> <!-- latin capital letter E with acute, + U+00C9 ISOlat1 --> +<!ENTITY Ecirc "Ê"> <!-- latin capital letter E with circumflex, + U+00CA ISOlat1 --> +<!ENTITY Euml "Ë"> <!-- latin capital letter E with diaeresis, + U+00CB ISOlat1 --> +<!ENTITY Igrave "Ì"> <!-- latin capital letter I with grave, + U+00CC ISOlat1 --> +<!ENTITY Iacute "Í"> <!-- latin capital letter I with acute, + U+00CD ISOlat1 --> +<!ENTITY Icirc "Î"> <!-- latin capital letter I with circumflex, + U+00CE ISOlat1 --> +<!ENTITY Iuml "Ï"> <!-- latin capital letter I with diaeresis, + U+00CF ISOlat1 --> +<!ENTITY ETH "Ð"> <!-- latin capital letter ETH, U+00D0 ISOlat1 --> +<!ENTITY Ntilde "Ñ"> <!-- latin capital letter N with tilde, + U+00D1 ISOlat1 --> +<!ENTITY Ograve "Ò"> <!-- latin capital letter O with grave, + U+00D2 ISOlat1 --> +<!ENTITY Oacute "Ó"> <!-- latin capital letter O with acute, + U+00D3 ISOlat1 --> +<!ENTITY Ocirc "Ô"> <!-- latin capital letter O with circumflex, + U+00D4 ISOlat1 --> +<!ENTITY Otilde "Õ"> <!-- latin capital letter O with tilde, + U+00D5 ISOlat1 --> +<!ENTITY Ouml "Ö"> <!-- latin capital letter O with diaeresis, + U+00D6 ISOlat1 --> +<!ENTITY times "×"> <!-- multiplication sign, U+00D7 ISOnum --> +<!ENTITY Oslash "Ø"> <!-- latin capital letter O with stroke + = latin capital letter O slash, + U+00D8 ISOlat1 --> +<!ENTITY Ugrave "Ù"> <!-- latin capital letter U with grave, + U+00D9 ISOlat1 --> +<!ENTITY Uacute "Ú"> <!-- latin capital letter U with acute, + U+00DA ISOlat1 --> +<!ENTITY Ucirc "Û"> <!-- latin capital letter U with circumflex, + U+00DB ISOlat1 --> +<!ENTITY Uuml "Ü"> <!-- latin capital letter U with diaeresis, + U+00DC ISOlat1 --> +<!ENTITY Yacute "Ý"> <!-- latin capital letter Y with acute, + U+00DD ISOlat1 --> +<!ENTITY THORN "Þ"> <!-- latin capital letter THORN, + U+00DE ISOlat1 --> +<!ENTITY szlig "ß"> <!-- latin small letter sharp s = ess-zed, + U+00DF ISOlat1 --> +<!ENTITY agrave "à"> <!-- latin small letter a with grave + = latin small letter a grave, + U+00E0 ISOlat1 --> +<!ENTITY aacute "á"> <!-- latin small letter a with acute, + U+00E1 ISOlat1 --> +<!ENTITY acirc "â"> <!-- latin small letter a with circumflex, + U+00E2 ISOlat1 --> +<!ENTITY atilde "ã"> <!-- latin small letter a with tilde, + U+00E3 ISOlat1 --> +<!ENTITY auml "ä"> <!-- latin small letter a with diaeresis, + U+00E4 ISOlat1 --> +<!ENTITY aring "å"> <!-- latin small letter a with ring above + = latin small letter a ring, + U+00E5 ISOlat1 --> +<!ENTITY aelig "æ"> <!-- latin small letter ae + = latin small ligature ae, U+00E6 ISOlat1 --> +<!ENTITY ccedil "ç"> <!-- latin small letter c with cedilla, + U+00E7 ISOlat1 --> +<!ENTITY egrave "è"> <!-- latin small letter e with grave, + U+00E8 ISOlat1 --> +<!ENTITY eacute "é"> <!-- latin small letter e with acute, + U+00E9 ISOlat1 --> +<!ENTITY ecirc "ê"> <!-- latin small letter e with circumflex, + U+00EA ISOlat1 --> +<!ENTITY euml "ë"> <!-- latin small letter e with diaeresis, + U+00EB ISOlat1 --> +<!ENTITY igrave "ì"> <!-- latin small letter i with grave, + U+00EC ISOlat1 --> +<!ENTITY iacute "í"> <!-- latin small letter i with acute, + U+00ED ISOlat1 --> +<!ENTITY icirc "î"> <!-- latin small letter i with circumflex, + U+00EE ISOlat1 --> +<!ENTITY iuml "ï"> <!-- latin small letter i with diaeresis, + U+00EF ISOlat1 --> +<!ENTITY eth "ð"> <!-- latin small letter eth, U+00F0 ISOlat1 --> +<!ENTITY ntilde "ñ"> <!-- latin small letter n with tilde, + U+00F1 ISOlat1 --> +<!ENTITY ograve "ò"> <!-- latin small letter o with grave, + U+00F2 ISOlat1 --> +<!ENTITY oacute "ó"> <!-- latin small letter o with acute, + U+00F3 ISOlat1 --> +<!ENTITY ocirc "ô"> <!-- latin small letter o with circumflex, + U+00F4 ISOlat1 --> +<!ENTITY otilde "õ"> <!-- latin small letter o with tilde, + U+00F5 ISOlat1 --> +<!ENTITY ouml "ö"> <!-- latin small letter o with diaeresis, + U+00F6 ISOlat1 --> +<!ENTITY divide "÷"> <!-- division sign, U+00F7 ISOnum --> +<!ENTITY oslash "ø"> <!-- latin small letter o with stroke, + = latin small letter o slash, + U+00F8 ISOlat1 --> +<!ENTITY ugrave "ù"> <!-- latin small letter u with grave, + U+00F9 ISOlat1 --> +<!ENTITY uacute "ú"> <!-- latin small letter u with acute, + U+00FA ISOlat1 --> +<!ENTITY ucirc "û"> <!-- latin small letter u with circumflex, + U+00FB ISOlat1 --> +<!ENTITY uuml "ü"> <!-- latin small letter u with diaeresis, + U+00FC ISOlat1 --> +<!ENTITY yacute "ý"> <!-- latin small letter y with acute, + U+00FD ISOlat1 --> +<!ENTITY thorn "þ"> <!-- latin small letter thorn, + U+00FE ISOlat1 --> +<!ENTITY yuml "ÿ"> <!-- latin small letter y with diaeresis, + U+00FF ISOlat1 --> + +<!--=================== Manually Loaded External character mnemonic entities: Symbols ===============================--> + +<!-- Latin Extended-B --> +<!ENTITY fnof "ƒ"> <!-- latin small letter f with hook = function + = florin, U+0192 ISOtech --> + +<!-- Greek --> +<!ENTITY Alpha "Α"> <!-- greek capital letter alpha, U+0391 --> +<!ENTITY Beta "Β"> <!-- greek capital letter beta, U+0392 --> +<!ENTITY Gamma "Γ"> <!-- greek capital letter gamma, + U+0393 ISOgrk3 --> +<!ENTITY Delta "Δ"> <!-- greek capital letter delta, + U+0394 ISOgrk3 --> +<!ENTITY Epsilon "Ε"> <!-- greek capital letter epsilon, U+0395 --> +<!ENTITY Zeta "Ζ"> <!-- greek capital letter zeta, U+0396 --> +<!ENTITY Eta "Η"> <!-- greek capital letter eta, U+0397 --> +<!ENTITY Theta "Θ"> <!-- greek capital letter theta, + U+0398 ISOgrk3 --> +<!ENTITY Iota "Ι"> <!-- greek capital letter iota, U+0399 --> +<!ENTITY Kappa "Κ"> <!-- greek capital letter kappa, U+039A --> +<!ENTITY Lambda "Λ"> <!-- greek capital letter lamda, + U+039B ISOgrk3 --> +<!ENTITY Mu "Μ"> <!-- greek capital letter mu, U+039C --> +<!ENTITY Nu "Ν"> <!-- greek capital letter nu, U+039D --> +<!ENTITY Xi "Ξ"> <!-- greek capital letter xi, U+039E ISOgrk3 --> +<!ENTITY Omicron "Ο"> <!-- greek capital letter omicron, U+039F --> +<!ENTITY Pi "Π"> <!-- greek capital letter pi, U+03A0 ISOgrk3 --> +<!ENTITY Rho "Ρ"> <!-- greek capital letter rho, U+03A1 --> +<!-- there is no Sigmaf, and no U+03A2 character either --> +<!ENTITY Sigma "Σ"> <!-- greek capital letter sigma, + U+03A3 ISOgrk3 --> +<!ENTITY Tau "Τ"> <!-- greek capital letter tau, U+03A4 --> +<!ENTITY Upsilon "Υ"> <!-- greek capital letter upsilon, + U+03A5 ISOgrk3 --> +<!ENTITY Phi "Φ"> <!-- greek capital letter phi, + U+03A6 ISOgrk3 --> +<!ENTITY Chi "Χ"> <!-- greek capital letter chi, U+03A7 --> +<!ENTITY Psi "Ψ"> <!-- greek capital letter psi, + U+03A8 ISOgrk3 --> +<!ENTITY Omega "Ω"> <!-- greek capital letter omega, + U+03A9 ISOgrk3 --> + +<!ENTITY alpha "α"> <!-- greek small letter alpha, + U+03B1 ISOgrk3 --> +<!ENTITY beta "β"> <!-- greek small letter beta, U+03B2 ISOgrk3 --> +<!ENTITY gamma "γ"> <!-- greek small letter gamma, + U+03B3 ISOgrk3 --> +<!ENTITY delta "δ"> <!-- greek small letter delta, + U+03B4 ISOgrk3 --> +<!ENTITY epsilon "ε"> <!-- greek small letter epsilon, + U+03B5 ISOgrk3 --> +<!ENTITY zeta "ζ"> <!-- greek small letter zeta, U+03B6 ISOgrk3 --> +<!ENTITY eta "η"> <!-- greek small letter eta, U+03B7 ISOgrk3 --> +<!ENTITY theta "θ"> <!-- greek small letter theta, + U+03B8 ISOgrk3 --> +<!ENTITY iota "ι"> <!-- greek small letter iota, U+03B9 ISOgrk3 --> +<!ENTITY kappa "κ"> <!-- greek small letter kappa, + U+03BA ISOgrk3 --> +<!ENTITY lambda "λ"> <!-- greek small letter lamda, + U+03BB ISOgrk3 --> +<!ENTITY mu "μ"> <!-- greek small letter mu, U+03BC ISOgrk3 --> +<!ENTITY nu "ν"> <!-- greek small letter nu, U+03BD ISOgrk3 --> +<!ENTITY xi "ξ"> <!-- greek small letter xi, U+03BE ISOgrk3 --> +<!ENTITY omicron "ο"> <!-- greek small letter omicron, U+03BF NEW --> +<!ENTITY pi "π"> <!-- greek small letter pi, U+03C0 ISOgrk3 --> +<!ENTITY rho "ρ"> <!-- greek small letter rho, U+03C1 ISOgrk3 --> +<!ENTITY sigmaf "ς"> <!-- greek small letter final sigma, + U+03C2 ISOgrk3 --> +<!ENTITY sigma "σ"> <!-- greek small letter sigma, + U+03C3 ISOgrk3 --> +<!ENTITY tau "τ"> <!-- greek small letter tau, U+03C4 ISOgrk3 --> +<!ENTITY upsilon "υ"> <!-- greek small letter upsilon, + U+03C5 ISOgrk3 --> +<!ENTITY phi "φ"> <!-- greek small letter phi, U+03C6 ISOgrk3 --> +<!ENTITY chi "χ"> <!-- greek small letter chi, U+03C7 ISOgrk3 --> +<!ENTITY psi "ψ"> <!-- greek small letter psi, U+03C8 ISOgrk3 --> +<!ENTITY omega "ω"> <!-- greek small letter omega, + U+03C9 ISOgrk3 --> +<!ENTITY thetasym "ϑ"> <!-- greek theta symbol, + U+03D1 NEW --> +<!ENTITY upsih "ϒ"> <!-- greek upsilon with hook symbol, + U+03D2 NEW --> +<!ENTITY piv "ϖ"> <!-- greek pi symbol, U+03D6 ISOgrk3 --> + +<!-- General Punctuation --> +<!ENTITY bull "•"> <!-- bullet = black small circle, + U+2022 ISOpub --> +<!-- bullet is NOT the same as bullet operator, U+2219 --> +<!ENTITY hellip "…"> <!-- horizontal ellipsis = three dot leader, + U+2026 ISOpub --> +<!ENTITY prime "′"> <!-- prime = minutes = feet, U+2032 ISOtech --> +<!ENTITY Prime "″"> <!-- double prime = seconds = inches, + U+2033 ISOtech --> +<!ENTITY oline "‾"> <!-- overline = spacing overscore, + U+203E NEW --> +<!ENTITY frasl "⁄"> <!-- fraction slash, U+2044 NEW --> + +<!-- Letterlike Symbols --> +<!ENTITY weierp "℘"> <!-- script capital P = power set + = Weierstrass p, U+2118 ISOamso --> +<!ENTITY image "ℑ"> <!-- black-letter capital I = imaginary part, + U+2111 ISOamso --> +<!ENTITY real "ℜ"> <!-- black-letter capital R = real part symbol, + U+211C ISOamso --> +<!ENTITY trade "™"> <!-- trade mark sign, U+2122 ISOnum --> +<!ENTITY alefsym "ℵ"> <!-- alef symbol = first transfinite cardinal, + U+2135 NEW --> +<!-- alef symbol is NOT the same as hebrew letter alef, + U+05D0 although the same glyph could be used to depict both characters --> + +<!-- Arrows --> +<!ENTITY larr "←"> <!-- leftwards arrow, U+2190 ISOnum --> +<!ENTITY uarr "↑"> <!-- upwards arrow, U+2191 ISOnum--> +<!ENTITY rarr "→"> <!-- rightwards arrow, U+2192 ISOnum --> +<!ENTITY darr "↓"> <!-- downwards arrow, U+2193 ISOnum --> +<!ENTITY harr "↔"> <!-- left right arrow, U+2194 ISOamsa --> +<!ENTITY crarr "↵"> <!-- downwards arrow with corner leftwards + = carriage return, U+21B5 NEW --> +<!ENTITY lArr "⇐"> <!-- leftwards double arrow, U+21D0 ISOtech --> +<!-- Unicode does not say that lArr is the same as the 'is implied by' arrow + but also does not have any other character for that function. So lArr can + be used for 'is implied by' as ISOtech suggests --> +<!ENTITY uArr "⇑"> <!-- upwards double arrow, U+21D1 ISOamsa --> +<!ENTITY rArr "⇒"> <!-- rightwards double arrow, + U+21D2 ISOtech --> +<!-- Unicode does not say this is the 'implies' character but does not have + another character with this function so rArr can be used for 'implies' + as ISOtech suggests --> +<!ENTITY dArr "⇓"> <!-- downwards double arrow, U+21D3 ISOamsa --> +<!ENTITY hArr "⇔"> <!-- left right double arrow, + U+21D4 ISOamsa --> + +<!-- Mathematical Operators --> +<!ENTITY forall "∀"> <!-- for all, U+2200 ISOtech --> +<!ENTITY part "∂"> <!-- partial differential, U+2202 ISOtech --> +<!ENTITY exist "∃"> <!-- there exists, U+2203 ISOtech --> +<!ENTITY empty "∅"> <!-- empty set = null set, U+2205 ISOamso --> +<!ENTITY nabla "∇"> <!-- nabla = backward difference, + U+2207 ISOtech --> +<!ENTITY isin "∈"> <!-- element of, U+2208 ISOtech --> +<!ENTITY notin "∉"> <!-- not an element of, U+2209 ISOtech --> +<!ENTITY ni "∋"> <!-- contains as member, U+220B ISOtech --> +<!ENTITY prod "∏"> <!-- n-ary product = product sign, + U+220F ISOamsb --> +<!-- prod is NOT the same character as U+03A0 'greek capital letter pi' though + the same glyph might be used for both --> +<!ENTITY sum "∑"> <!-- n-ary summation, U+2211 ISOamsb --> +<!-- sum is NOT the same character as U+03A3 'greek capital letter sigma' + though the same glyph might be used for both --> +<!ENTITY minus "−"> <!-- minus sign, U+2212 ISOtech --> +<!ENTITY lowast "∗"> <!-- asterisk operator, U+2217 ISOtech --> +<!ENTITY radic "√"> <!-- square root = radical sign, + U+221A ISOtech --> +<!ENTITY prop "∝"> <!-- proportional to, U+221D ISOtech --> +<!ENTITY infin "∞"> <!-- infinity, U+221E ISOtech --> +<!ENTITY ang "∠"> <!-- angle, U+2220 ISOamso --> +<!ENTITY and "∧"> <!-- logical and = wedge, U+2227 ISOtech --> +<!ENTITY or "∨"> <!-- logical or = vee, U+2228 ISOtech --> +<!ENTITY cap "∩"> <!-- intersection = cap, U+2229 ISOtech --> +<!ENTITY cup "∪"> <!-- union = cup, U+222A ISOtech --> +<!ENTITY int "∫"> <!-- integral, U+222B ISOtech --> +<!ENTITY there4 "∴"> <!-- therefore, U+2234 ISOtech --> +<!ENTITY sim "∼"> <!-- tilde operator = varies with = similar to, + U+223C ISOtech --> +<!-- tilde operator is NOT the same character as the tilde, U+007E, + although the same glyph might be used to represent both --> +<!ENTITY cong "≅"> <!-- approximately equal to, U+2245 ISOtech --> +<!ENTITY asymp "≈"> <!-- almost equal to = asymptotic to, + U+2248 ISOamsr --> +<!ENTITY ne "≠"> <!-- not equal to, U+2260 ISOtech --> +<!ENTITY equiv "≡"> <!-- identical to, U+2261 ISOtech --> +<!ENTITY le "≤"> <!-- less-than or equal to, U+2264 ISOtech --> +<!ENTITY ge "≥"> <!-- greater-than or equal to, + U+2265 ISOtech --> +<!ENTITY sub "⊂"> <!-- subset of, U+2282 ISOtech --> +<!ENTITY sup "⊃"> <!-- superset of, U+2283 ISOtech --> +<!ENTITY nsub "⊄"> <!-- not a subset of, U+2284 ISOamsn --> +<!ENTITY sube "⊆"> <!-- subset of or equal to, U+2286 ISOtech --> +<!ENTITY supe "⊇"> <!-- superset of or equal to, + U+2287 ISOtech --> +<!ENTITY oplus "⊕"> <!-- circled plus = direct sum, + U+2295 ISOamsb --> +<!ENTITY otimes "⊗"> <!-- circled times = vector product, + U+2297 ISOamsb --> +<!ENTITY perp "⊥"> <!-- up tack = orthogonal to = perpendicular, + U+22A5 ISOtech --> +<!ENTITY sdot "⋅"> <!-- dot operator, U+22C5 ISOamsb --> +<!-- dot operator is NOT the same character as U+00B7 middle dot --> + +<!-- Miscellaneous Technical --> +<!ENTITY lceil "⌈"> <!-- left ceiling = APL upstile, + U+2308 ISOamsc --> +<!ENTITY rceil "⌉"> <!-- right ceiling, U+2309 ISOamsc --> +<!ENTITY lfloor "⌊"> <!-- left floor = APL downstile, + U+230A ISOamsc --> +<!ENTITY rfloor "⌋"> <!-- right floor, U+230B ISOamsc --> +<!ENTITY lang "〈"> <!-- left-pointing angle bracket = bra, + U+2329 ISOtech --> +<!-- lang is NOT the same character as U+003C 'less than sign' + or U+2039 'single left-pointing angle quotation mark' --> +<!ENTITY rang "〉"> <!-- right-pointing angle bracket = ket, + U+232A ISOtech --> +<!-- rang is NOT the same character as U+003E 'greater than sign' + or U+203A 'single right-pointing angle quotation mark' --> + +<!-- Geometric Shapes --> +<!ENTITY loz "◊"> <!-- lozenge, U+25CA ISOpub --> + +<!-- Miscellaneous Symbols --> +<!ENTITY spades "♠"> <!-- black spade suit, U+2660 ISOpub --> +<!-- black here seems to mean filled as opposed to hollow --> +<!ENTITY clubs "♣"> <!-- black club suit = shamrock, + U+2663 ISOpub --> +<!ENTITY hearts "♥"> <!-- black heart suit = valentine, + U+2665 ISOpub --> +<!ENTITY diams "♦"> <!-- black diamond suit, U+2666 ISOpub --> + + + +<!--=================== Manually Loaded External character mnemonic entities: Special ===============================--> + +<!-- C0 Controls and Basic Latin --> +<!ENTITY quot """> <!-- quotation mark, U+0022 ISOnum --> +<!ENTITY amp "&#38;"> <!-- ampersand, U+0026 ISOnum --> +<!ENTITY lt "&#60;"> <!-- less-than sign, U+003C ISOnum --> +<!ENTITY gt ">"> <!-- greater-than sign, U+003E ISOnum --> +<!ENTITY apos "'"> <!-- apostrophe = APL quote, U+0027 ISOnum --> + +<!-- Latin Extended-A --> +<!ENTITY OElig "Œ"> <!-- latin capital ligature OE, + U+0152 ISOlat2 --> +<!ENTITY oelig "œ"> <!-- latin small ligature oe, U+0153 ISOlat2 --> +<!-- ligature is a misnomer, this is a separate character in some languages --> +<!ENTITY Scaron "Š"> <!-- latin capital letter S with caron, + U+0160 ISOlat2 --> +<!ENTITY scaron "š"> <!-- latin small letter s with caron, + U+0161 ISOlat2 --> +<!ENTITY Yuml "Ÿ"> <!-- latin capital letter Y with diaeresis, + U+0178 ISOlat2 --> + +<!-- Spacing Modifier Letters --> +<!ENTITY circ "ˆ"> <!-- modifier letter circumflex accent, + U+02C6 ISOpub --> +<!ENTITY tilde "˜"> <!-- small tilde, U+02DC ISOdia --> + +<!-- General Punctuation --> +<!ENTITY ensp " "> <!-- en space, U+2002 ISOpub --> +<!ENTITY emsp " "> <!-- em space, U+2003 ISOpub --> +<!ENTITY thinsp " "> <!-- thin space, U+2009 ISOpub --> +<!ENTITY zwnj "‌"> <!-- zero width non-joiner, + U+200C NEW RFC 2070 --> +<!ENTITY zwj "‍"> <!-- zero width joiner, U+200D NEW RFC 2070 --> +<!ENTITY lrm "‎"> <!-- left-to-right mark, U+200E NEW RFC 2070 --> +<!ENTITY rlm "‏"> <!-- right-to-left mark, U+200F NEW RFC 2070 --> +<!ENTITY ndash "–"> <!-- en dash, U+2013 ISOpub --> +<!ENTITY mdash "—"> <!-- em dash, U+2014 ISOpub --> +<!ENTITY lsquo "‘"> <!-- left single quotation mark, + U+2018 ISOnum --> +<!ENTITY rsquo "’"> <!-- right single quotation mark, + U+2019 ISOnum --> +<!ENTITY sbquo "‚"> <!-- single low-9 quotation mark, U+201A NEW --> +<!ENTITY ldquo "“"> <!-- left double quotation mark, + U+201C ISOnum --> +<!ENTITY rdquo "”"> <!-- right double quotation mark, + U+201D ISOnum --> +<!ENTITY bdquo "„"> <!-- double low-9 quotation mark, U+201E NEW --> +<!ENTITY dagger "†"> <!-- dagger, U+2020 ISOpub --> +<!ENTITY Dagger "‡"> <!-- double dagger, U+2021 ISOpub --> +<!ENTITY permil "‰"> <!-- per mille sign, U+2030 ISOtech --> +<!ENTITY lsaquo "‹"> <!-- single left-pointing angle quotation mark, + U+2039 ISO proposed --> +<!-- lsaquo is proposed but not yet ISO standardized --> +<!ENTITY rsaquo "›"> <!-- single right-pointing angle quotation mark, + U+203A ISO proposed --> +<!-- rsaquo is proposed but not yet ISO standardized --> + +<!-- Currency Symbols --> +<!ENTITY euro "€"> <!-- euro sign, U+20AC NEW --> + + + <!--=================== Generic Attributes ===============================--> <!ENTITY % coreattrs diff --git a/anknotes/extra/ancillary/regex-see_also.txt b/anknotes/extra/ancillary/regex-see_also.txt index f2a0c80..a7b0182 100644 --- a/anknotes/extra/ancillary/regex-see_also.txt +++ b/anknotes/extra/ancillary/regex-see_also.txt @@ -2,9 +2,13 @@ (?P<SeeAlso> (?P<SeeAlsoHeaderDiv><div[^>]*>) (?P<SeeAlsoHeader> - (?P<SeeAlsoHeaderPrefix>(?:<(?:span|b|font)[^>]*>){0,5}) - (?P<SeeAlsoHeaderStripMe><br />(?:\r|\n|\r\n)?)? - See.[Aa]lso:?(?:\ | )? + (?P<SeeAlsoHeaderPrefix> + (?P<SeeAlsoHeaderPrefixOpen>(?:<(?:span|b|font|br)[^>]*>){0,5}) + (?P<SeeAlsoHeaderStripMe><br />(?:\r|\n|\r\n)?)? + (?P<SeeAlsoHeaderPrefixClose>(?:</(?:span|b|font|div)>){0,2}) + (?P<SeeAlsoHeaderPrefixReopen>(?:<(?:span|b|font|br)[^>]*>){0,1}) + ) + See.[Aa]lso:?(?:\ | )? (?P<SeeAlsoHeaderSuffix>(?:</(?:span|b|font)>){0,5}) ) (?P<SeeAlsoContent> diff --git a/anknotes/extra/dev/invisible.vbs b/anknotes/extra/dev/invisible.vbs index 99c3552..ab66213 100644 --- a/anknotes/extra/dev/invisible.vbs +++ b/anknotes/extra/dev/invisible.vbs @@ -1 +1,6 @@ -CreateObject("Wscript.Shell").Run """" & WScript.Arguments(0) & """", 0, True \ No newline at end of file +args = "" +For I = 0 to Wscript.Arguments.Count - 1 + args = args & """" & WScript.Arguments(i) & """ " +Next + +CreateObject("Wscript.Shell").Run args, 0, False \ No newline at end of file diff --git a/anknotes/find_deleted_notes.py b/anknotes/find_deleted_notes.py index 584d917..34bc67e 100644 --- a/anknotes/find_deleted_notes.py +++ b/anknotes/find_deleted_notes.py @@ -1,133 +1,133 @@ # -*- coding: utf-8 -*- try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite from anknotes.shared import * def do_find_deleted_notes(all_anki_notes=None): - """ - :param all_anki_notes: from Anki.get_evernote_guids_and_anki_fields_from_anki_note_ids() - :type : dict[str, dict[str, str]] - :return: - """ - - Error = sqlite.Error - - enTableOfContents = file(ANKNOTES.TABLE_OF_CONTENTS_ENEX, 'r').read() - # find = file(os.path.join(PATH, "powergrep-find.txt") , 'r').read().splitlines() - # replace = file(os.path.join(PATH, "powergrep-replace.txt") , 'r').read().replace('https://www.evernote.com/shard/s175/nl/19775535/' , '').splitlines() - - all_anknotes_notes = ankDB().all("SELECT guid, title, tagNames FROM %s " % TABLES.EVERNOTE.NOTES) - find_guids = {} - log_banner(' FIND DELETED EVERNOTE NOTES: UNIMPORTED EVERNOTE NOTES ', ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES) - log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKI NOTES ', ANKNOTES.LOG_FDN_ANKI_ORPHANS) - log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKNOTES DB ENTRIES ', ANKNOTES.LOG_FDN_ANKNOTES_ORPHANS) - log_banner(' FIND DELETED EVERNOTE NOTES: ANKNOTES TITLE MISMATCHES ', ANKNOTES.LOG_FDN_ANKNOTES_TITLE_MISMATCHES) - log_banner(' FIND DELETED EVERNOTE NOTES: ANKI TITLE MISMATCHES ', ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES) - log_banner(' FIND DELETED EVERNOTE NOTES: POSSIBLE TOC NOTES MISSING TAG ', ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES + '_possibletoc') - anki_mismatch = 0 - is_toc_or_outline=[] - for line in all_anknotes_notes: - guid = line['guid'] - title = line['title'] - if not (',' + EVERNOTE.TAG.TOC + ',' in line['tagNames']): - if title.upper() == title: - log_plain(guid + '::: %-50s: ' % line['tagNames'][1:-1] + title, ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES + '_possibletoc', do_print=True) - - title = clean_title(title) - title_safe = str_safe(title) - find_guids[guid] = title - if all_anki_notes: - if guid in all_anki_notes: - find_title = all_anki_notes[guid][FIELDS.TITLE] - find_title_safe = str_safe(find_title) - if find_title_safe == title_safe: - del all_anki_notes[guid] - else: - log_plain(guid + '::: ' + title, ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES) - anki_mismatch += 1 - mismatch = 0 - missing_evernote_notes = [] - for enLink in find_evernote_links(enTableOfContents): - guid = enLink.Guid - title = clean_title(enLink.FullTitle) - title_safe = str_safe(title) - - if guid in find_guids: - find_title = find_guids[guid] - find_title_safe = str_safe(find_title) - if find_title_safe == title_safe: - del find_guids[guid] - else: - log_plain(guid + '::: ' + title, ANKNOTES.LOG_FDN_ANKNOTES_TITLE_MISMATCHES) - mismatch += 1 - else: - log_plain(guid + '::: ' + title, ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES) - missing_evernote_notes.append(guid) - - anki_dels = [] - anknotes_dels = [] - if all_anki_notes: - for guid, fields in all_anki_notes.items(): - log_plain(guid + '::: ' + fields[FIELDS.TITLE], ANKNOTES.LOG_FDN_ANKI_ORPHANS) - anki_dels.append(guid) - for guid, title in find_guids.items(): - log_plain(guid + '::: ' + title, ANKNOTES.LOG_FDN_ANKNOTES_ORPHANS) - anknotes_dels.append(guid) - - logs = [ - ["Orphan Anknotes DB Note(s)", - - len(anknotes_dels), - ANKNOTES.LOG_FDN_ANKNOTES_ORPHANS, - "(not present in Evernote)" - - ], - - ["Orphan Anki Note(s)", - - len(anki_dels), - ANKNOTES.LOG_FDN_ANKI_ORPHANS, - "(not present in Anknotes DB)" - - ], - - ["Unimported Evernote Note(s)", - - len(missing_evernote_notes), - ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES, - "(not present in Anknotes DB" - - ], - - ["Anknotes DB Title Mismatches", - - mismatch, - ANKNOTES.LOG_FDN_ANKNOTES_TITLE_MISMATCHES - - ], - - ["Anki Title Mismatches", - - anki_mismatch, - ANKNOTES.LOG_FDN_ANKI_TITLE_MISMATCHES - - ] - ] - results = [ - [ - log[1], - log[0] if log[1] == 0 else '<a href="%s">%s</a>' % (get_log_full_path(log[2], as_url_link=True), log[0]), - log[3] if len(log) > 3 else '' - ] - for log in logs] - - # showInfo(str(results)) - - return { - "Summary": results, "AnknotesOrphans": anknotes_dels, "AnkiOrphans": anki_dels, - "MissingEvernoteNotes": missing_evernote_notes - } + """ + :param all_anki_notes: from Anki.get_evernote_guids_and_anki_fields_from_anki_note_ids() + :type : dict[str, dict[str, str]] + :return: + """ + + Error = sqlite.Error + + enTableOfContents = file(FILES.USER.TABLE_OF_CONTENTS_ENEX, 'r').read() + # find = file(os.path.join(PATH, "powergrep-find.txt") , 'r').read().splitlines() + # replace = file(os.path.join(PATH, "powergrep-replace.txt") , 'r').read().replace('https://www.evernote.com/shard/s175/nl/19775535/' , '').splitlines() + + all_anknotes_notes = ankDB().all("SELECT guid, title, tagNames FROM %s " % TABLES.EVERNOTE.NOTES) + find_guids = {} + log_banner(' FIND DELETED EVERNOTE NOTES: UNIMPORTED EVERNOTE NOTES ', FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES) + log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKI NOTES ', FILES.LOGS.FDN.ANKI_ORPHANS) + log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKNOTES DB ENTRIES ', FILES.LOGS.FDN.ANKNOTES_ORPHANS) + log_banner(' FIND DELETED EVERNOTE NOTES: ANKNOTES TITLE MISMATCHES ', FILES.LOGS.FDN.ANKNOTES_TITLE_MISMATCHES) + log_banner(' FIND DELETED EVERNOTE NOTES: ANKI TITLE MISMATCHES ', FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES) + log_banner(' FIND DELETED EVERNOTE NOTES: POSSIBLE TOC NOTES MISSING TAG ', FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES + '_possibletoc') + anki_mismatch = 0 + is_toc_or_outline=[] + for line in all_anknotes_notes: + guid = line['guid'] + title = line['title'] + if not (',' + TAGS.TOC + ',' in line['tagNames']): + if title.upper() == title: + log_plain(guid + '::: %-50s: ' % line['tagNames'][1:-1] + title, FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES + '_possibletoc', do_print=True) + + title = clean_title(title) + title_safe = str_safe(title) + find_guids[guid] = title + if all_anki_notes: + if guid in all_anki_notes: + find_title = all_anki_notes[guid][FIELDS.TITLE] + find_title_safe = str_safe(find_title) + if find_title_safe == title_safe: + del all_anki_notes[guid] + else: + log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES) + anki_mismatch += 1 + mismatch = 0 + missing_evernote_notes = [] + for enLink in find_evernote_links(enTableOfContents): + guid = enLink.Guid + title = clean_title(enLink.FullTitle) + title_safe = str_safe(title) + + if guid in find_guids: + find_title = find_guids[guid] + find_title_safe = str_safe(find_title) + if find_title_safe == title_safe: + del find_guids[guid] + else: + log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKNOTES_TITLE_MISMATCHES) + mismatch += 1 + else: + log_plain(guid + '::: ' + title, FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES) + missing_evernote_notes.append(guid) + + anki_dels = [] + anknotes_dels = [] + if all_anki_notes: + for guid, fields in all_anki_notes.items(): + log_plain(guid + '::: ' + fields[FIELDS.TITLE], FILES.LOGS.FDN.ANKI_ORPHANS) + anki_dels.append(guid) + for guid, title in find_guids.items(): + log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKNOTES_ORPHANS) + anknotes_dels.append(guid) + + logs = [ + ["Orphan Anknotes DB Note(s)", + + len(anknotes_dels), + FILES.LOGS.FDN.ANKNOTES_ORPHANS, + "(not present in Evernote)" + + ], + + ["Orphan Anki Note(s)", + + len(anki_dels), + FILES.LOGS.FDN.ANKI_ORPHANS, + "(not present in Anknotes DB)" + + ], + + ["Unimported Evernote Note(s)", + + len(missing_evernote_notes), + FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES, + "(not present in Anknotes DB" + + ], + + ["Anknotes DB Title Mismatches", + + mismatch, + FILES.LOGS.FDN.ANKNOTES_TITLE_MISMATCHES + + ], + + ["Anki Title Mismatches", + + anki_mismatch, + FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES + + ] + ] + results = [ + [ + log[1], + log[0] if log[1] == 0 else '<a href="%s">%s</a>' % (get_log_full_path(log[2], as_url_link=True), log[0]), + log[3] if len(log) > 3 else '' + ] + for log in logs] + + # showInfo(str(results)) + + return { + "Summary": results, "AnknotesOrphans": anknotes_dels, "AnkiOrphans": anki_dels, + "MissingEvernoteNotes": missing_evernote_notes + } diff --git a/anknotes/graphics.py b/anknotes/graphics.py index 438fa1c..3fe2295 100644 --- a/anknotes/graphics.py +++ b/anknotes/graphics.py @@ -1,15 +1,15 @@ from anknotes.constants import * ### Anki Imports try: - from aqt.qt import QIcon, QPixmap + from aqt.qt import QIcon, QPixmap except: - pass + pass try: - icoEvernoteWeb = QIcon(ANKNOTES.ICON_EVERNOTE_WEB) - icoEvernoteArtcore = QIcon(ANKNOTES.ICON_EVERNOTE_ARTCORE) - icoTomato = QIcon(ANKNOTES.ICON_TOMATO) - imgEvernoteWeb = QPixmap(ANKNOTES.IMAGE_EVERNOTE_WEB, "PNG") - imgEvernoteWebMsgBox = imgEvernoteWeb.scaledToWidth(64) + icoEvernoteWeb = QIcon(FILES.GRAPHICS.ICON.EVERNOTE_WEB) + icoEvernoteArtcore = QIcon(FILES.GRAPHICS.ICON.EVERNOTE_ARTCORE) + icoTomato = QIcon(FILES.GRAPHICS.ICON.TOMATO) + imgEvernoteWeb = QPixmap(FILES.GRAPHICS.IMAGE.EVERNOTE_WEB, "PNG") + imgEvernoteWebMsgBox = imgEvernoteWeb.scaledToWidth(64) except: - pass + pass diff --git a/anknotes/html.py b/anknotes/html.py index 734a5e9..985c6fd 100644 --- a/anknotes/html.py +++ b/anknotes/html.py @@ -8,196 +8,196 @@ except: pass class MLStripper(HTMLParser): - def __init__(self): - HTMLParser.__init__(self) - self.reset() - self.fed = [] + def __init__(self): + HTMLParser.__init__(self) + self.reset() + self.fed = [] - def handle_data(self, d): - self.fed.append(d) + def handle_data(self, d): + self.fed.append(d) - def get_data(self): - return ''.join(self.fed) + def get_data(self): + return ''.join(self.fed) def strip_tags(html): - if html is None: return None - html = html.replace('&', '__DONT_STRIP_HTML_ENTITIES___') - s = MLStripper() - s.feed(html) - html = s.get_data() - html = html.replace('__DONT_STRIP_HTML_ENTITIES___', '&') - return html - # s = MLStripper() - # s.feed(html) - # return s.get_data() + if html is None: return None + html = html.replace('&', '__DONT_STRIP_HTML_ENTITIES___') + s = MLStripper() + s.feed(html) + html = s.get_data() + html = html.replace('__DONT_STRIP_HTML_ENTITIES___', '&') + return html + # s = MLStripper() + # s.feed(html) + # return s.get_data() def strip_tags_and_new_lines(html): - if html is None: return None - return re.sub(r'[\r\n]+', ' ', strip_tags(html)) + if html is None: return None + return re.sub(r'[\r\n]+', ' ', strip_tags(html)) __text_escape_phrases__ = u'&|&|\'|'|"|"|>|>|<|<'.split('|') def escape_text(title): - global __text_escape_phrases__ - for i in range(0, len(__text_escape_phrases__), 2): - title = title.replace(__text_escape_phrases__[i], __text_escape_phrases__[i + 1]) - return title + global __text_escape_phrases__ + for i in range(0, len(__text_escape_phrases__), 2): + title = title.replace(__text_escape_phrases__[i], __text_escape_phrases__[i + 1]) + return title def unescape_text(title, try_decoding=False): - title_orig = title - global __text_escape_phrases__ - if try_decoding: title = title.decode('utf-8') - try: - for i in range(0, len(__text_escape_phrases__), 2): - title = title.replace(__text_escape_phrases__[i + 1], __text_escape_phrases__[i]) - title = title.replace(u" ", u" ") - except: - if try_decoding: raise UnicodeError - title_new = unescape_text(title, True) - log(title + '\n' + title_new + '\n\n', 'unicode') - return title_new - return title + title_orig = title + global __text_escape_phrases__ + if try_decoding: title = title.decode('utf-8') + try: + for i in range(0, len(__text_escape_phrases__), 2): + title = title.replace(__text_escape_phrases__[i + 1], __text_escape_phrases__[i]) + title = title.replace(u" ", u" ") + except: + if try_decoding: raise UnicodeError + title_new = unescape_text(title, True) + log(title + '\n' + title_new + '\n\n', 'unicode') + return title_new + return title def clean_title(title): - if isinstance(title, str): - title = unicode(title, 'utf-8') - title = unescape_text(title) - if isinstance(title, str): - title = unicode(title, 'utf-8') - title = title.replace(u'\xa0', ' ') - return title + if isinstance(title, str): + title = unicode(title, 'utf-8') + title = unescape_text(title) + if isinstance(title, str): + title = unicode(title, 'utf-8') + title = title.replace(u'\xa0', ' ') + return title def generate_evernote_url(guid): - ids = get_evernote_account_ids() - return u'evernote:///view/%s/%s/%s/%s/' % (ids.uid, ids.shard, guid, guid) + ids = get_evernote_account_ids() + return u'evernote:///view/%s/%s/%s/%s/' % (ids.uid, ids.shard, guid, guid) def generate_evernote_link_by_type(guid, title=None, link_type=None, value=None, escape=True): - url = generate_evernote_url(guid) - if not title: title = get_evernote_title_from_guid(guid) - if escape: title = escape_text(title) - style = generate_evernote_html_element_style_attribute(link_type, value) - html = u"""<a href='%s'><span style='%s'>%s</span></a>""" % (url, style, title) - # print html - return html + url = generate_evernote_url(guid) + if not title: title = get_evernote_title_from_guid(guid) + if escape: title = escape_text(title) + style = generate_evernote_html_element_style_attribute(link_type, value) + html = u"""<a href="%s"><span style="%s">%s</span></a>""" % (url, style, title) + # print html + return html def generate_evernote_link(guid, title=None, value=None, escape=True): - return generate_evernote_link_by_type(guid, title, 'Links', value, escape=escape) + return generate_evernote_link_by_type(guid, title, 'Links', value, escape=escape) def generate_evernote_link_by_level(guid, title=None, value=None, escape=True): - return generate_evernote_link_by_type(guid, title, 'Levels', value, escape=escape) + return generate_evernote_link_by_type(guid, title, 'Levels', value, escape=escape) def generate_evernote_html_element_style_attribute(link_type, value, bold=True, group=None): - global evernote_link_colors - colors = None - if link_type in evernote_link_colors: - color_types = evernote_link_colors[link_type] - if link_type is 'Levels': - if not value: value = 1 - if not group: group = 'OL' if isinstance(value, int) else 'Modifiers' - if not value in color_types[group]: group = 'Headers' - if value in color_types[group]: - colors = color_types[group][value] - elif link_type is 'Links': - if not value: value = 'Default' - if value in color_types: - colors = color_types[value] - if not colors: - colors = evernote_link_colors['Default'] - colorDefault = colors - if not isinstance(colorDefault, str) and not isinstance(colorDefault, unicode): - colorDefault = colorDefault['Default'] - if not colorDefault[-1] is ';': colorDefault += ';' - style = 'color: ' + colorDefault - if bold: style += 'font-weight:bold;' - return style + global evernote_link_colors + colors = None + if link_type in evernote_link_colors: + color_types = evernote_link_colors[link_type] + if link_type is 'Levels': + if not value: value = 1 + if not group: group = 'OL' if isinstance(value, int) else 'Modifiers' + if not value in color_types[group]: group = 'Headers' + if value in color_types[group]: + colors = color_types[group][value] + elif link_type is 'Links': + if not value: value = 'Default' + if value in color_types: + colors = color_types[value] + if not colors: + colors = evernote_link_colors['Default'] + colorDefault = colors + if not isinstance(colorDefault, str) and not isinstance(colorDefault, unicode): + colorDefault = colorDefault['Default'] + if not colorDefault[-1] is ';': colorDefault += ';' + style = 'color: ' + colorDefault + if bold: style += 'font-weight:bold;' + return style def generate_evernote_span(title=None, element_type=None, value=None, guid=None, bold=True, escape=True): - assert title or guid - if not title: title = get_evernote_title_from_guid(guid) - if escape: title = escape_text(title) - style = generate_evernote_html_element_style_attribute(element_type, value, bold) - html = u"<span style='%s'>%s</span>" % (style, title) - return html + assert title or guid + if not title: title = get_evernote_title_from_guid(guid) + if escape: title = escape_text(title) + style = generate_evernote_html_element_style_attribute(element_type, value, bold) + html = u"""<span style="%s">%s</span>""" % (style, title) + return html evernote_link_colors = { - 'Levels': { - 'OL': { - 1: { - 'Default': 'rgb(106, 0, 129);', - 'Hover': 'rgb(168, 0, 204);' - }, - 2: { - 'Default': 'rgb(235, 0, 115);', - 'Hover': 'rgb(255, 94, 174);' - }, - 3: { - 'Default': 'rgb(186, 0, 255);', - 'Hover': 'rgb(213, 100, 255);' - }, - 4: { - 'Default': 'rgb(129, 182, 255);', - 'Hover': 'rgb(36, 130, 255);' - }, - 5: { - 'Default': 'rgb(232, 153, 220);', - 'Hover': 'rgb(142, 32, 125);' - }, - 6: { - 'Default': 'rgb(201, 213, 172);', - 'Hover': 'rgb(130, 153, 77);' - }, - 7: { - 'Default': 'rgb(231, 179, 154);', - 'Hover': 'rgb(215, 129, 87);' - }, - 8: { - 'Default': 'rgb(249, 136, 198);', - 'Hover': 'rgb(215, 11, 123);' - } - }, - 'Headers': { - 'Auto TOC': 'rgb(11, 59, 225);' - }, - 'Modifiers': { - 'Orange': 'rgb(222, 87, 0);', - 'Orange (Light)': 'rgb(250, 122, 0);', - 'Dark Red/Pink': 'rgb(164, 15, 45);', - 'Pink Alternative LVL1:': 'rgb(188, 0, 88);' - } - }, - 'Titles': { - 'Field Title Prompt': 'rgb(169, 0, 48);' - }, - 'Links': { - 'See Also': { - 'Default': 'rgb(45, 79, 201);', - 'Hover': 'rgb(108, 132, 217);' - }, - 'TOC': { - 'Default': 'rgb(173, 0, 0);', - 'Hover': 'rgb(196, 71, 71);' - }, - 'Outline': { - 'Default': 'rgb(105, 170, 53);', - 'Hover': 'rgb(135, 187, 93);' - }, - 'AnkNotes': { - 'Default': 'rgb(30, 155, 67);', - 'Hover': 'rgb(107, 226, 143);' - } - } + 'Levels': { + 'OL': { + 1: { + 'Default': 'rgb(106, 0, 129);', + 'Hover': 'rgb(168, 0, 204);' + }, + 2: { + 'Default': 'rgb(235, 0, 115);', + 'Hover': 'rgb(255, 94, 174);' + }, + 3: { + 'Default': 'rgb(186, 0, 255);', + 'Hover': 'rgb(213, 100, 255);' + }, + 4: { + 'Default': 'rgb(129, 182, 255);', + 'Hover': 'rgb(36, 130, 255);' + }, + 5: { + 'Default': 'rgb(232, 153, 220);', + 'Hover': 'rgb(142, 32, 125);' + }, + 6: { + 'Default': 'rgb(201, 213, 172);', + 'Hover': 'rgb(130, 153, 77);' + }, + 7: { + 'Default': 'rgb(231, 179, 154);', + 'Hover': 'rgb(215, 129, 87);' + }, + 8: { + 'Default': 'rgb(249, 136, 198);', + 'Hover': 'rgb(215, 11, 123);' + } + }, + 'Headers': { + 'Auto TOC': 'rgb(11, 59, 225);' + }, + 'Modifiers': { + 'Orange': 'rgb(222, 87, 0);', + 'Orange (Light)': 'rgb(250, 122, 0);', + 'Dark Red/Pink': 'rgb(164, 15, 45);', + 'Pink Alternative LVL1:': 'rgb(188, 0, 88);' + } + }, + 'Titles': { + 'Field Title Prompt': 'rgb(169, 0, 48);' + }, + 'Links': { + 'See Also': { + 'Default': 'rgb(45, 79, 201);', + 'Hover': 'rgb(108, 132, 217);' + }, + 'TOC': { + 'Default': 'rgb(173, 0, 0);', + 'Hover': 'rgb(196, 71, 71);' + }, + 'Outline': { + 'Default': 'rgb(105, 170, 53);', + 'Hover': 'rgb(135, 187, 93);' + }, + 'AnkNotes': { + 'Default': 'rgb(30, 155, 67);', + 'Hover': 'rgb(107, 226, 143);' + } + } } evernote_link_colors['Default'] = evernote_link_colors['Links']['Outline'] @@ -207,57 +207,57 @@ def generate_evernote_span(title=None, element_type=None, value=None, guid=None, def get_evernote_account_ids(): - global enAccountIDs - if not enAccountIDs or not enAccountIDs.Valid: - enAccountIDs = EvernoteAccountIDs() - return enAccountIDs + global enAccountIDs + if not enAccountIDs or not enAccountIDs.Valid: + enAccountIDs = EvernoteAccountIDs() + return enAccountIDs def tableify_column(column): - return str(column).replace('\n', '\n<BR>').replace(' ', '  ') + return str(column).replace('\n', '\n<BR>').replace(' ', '  ') def tableify_lines(rows, columns=None, tr_index_offset=0, return_html=True): - if columns is None: columns = [] - elif not isinstance(columns, list): columns = [columns] - trs = ['<tr class="tr%d%s">%s\n</tr>\n' % (i_row, ' alt' if i_row % 2 is 0 else ' std', ''.join(['\n <td class="td%d%s">%s</td>' % (i_col+1, ' alt' if i_col % 2 is 0 else ' std', tableify_column(column)) for i_col, column in enumerate(row if isinstance(row, list) else row.split('|'))])) for i_row, row in enumerate(columns + rows)] - if return_html: - return "<table cellspacing='0' style='border: 1px solid black;border-collapse: collapse;'>\n%s</table>" % ''.join(trs) - return trs + if columns is None: columns = [] + elif not isinstance(columns, list): columns = [columns] + trs = ['<tr class="tr%d%s">%s\n</tr>\n' % (i_row, ' alt' if i_row % 2 is 0 else ' std', ''.join(['\n <td class="td%d%s">%s</td>' % (i_col+1, ' alt' if i_col % 2 is 0 else ' std', tableify_column(column)) for i_col, column in enumerate(row if isinstance(row, list) else row.split('|'))])) for i_row, row in enumerate(columns + rows)] + if return_html: + return "<table cellspacing='0' style='border: 1px solid black;border-collapse: collapse;'>\n%s</table>" % ''.join(trs) + return trs class EvernoteAccountIDs: - uid = SETTINGS.EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE - shard = SETTINGS.EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE - - @property - def Valid(self): - return self.is_valid() - - def is_valid(self, uid=None, shard=None): - if uid is None: uid = self.uid - if shard is None: shard = self.shard - if not uid or not shard: return False - if uid == '0' or uid == SETTINGS.EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE or not unicode(uid).isnumeric(): return False - if shard == 's999' or uid == SETTINGS.EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE or shard[0] != 's' or not unicode(shard[1:]).isnumeric(): return False - return True - - def __init__(self, uid=None, shard=None): - if uid and shard: - if self.update(uid, shard): return - try: - self.uid = mw.col.conf.get(SETTINGS.EVERNOTE_ACCOUNT_UID, SETTINGS.EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE) - self.shard = mw.col.conf.get(SETTINGS.EVERNOTE_ACCOUNT_SHARD, SETTINGS.EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE) - if self.Valid: return - except: - pass - self.uid = SETTINGS.EVERNOTE_ACCOUNT_UID_DEFAULT_VALUE - self.shard = SETTINGS.EVERNOTE_ACCOUNT_SHARD_DEFAULT_VALUE - - def update(self, uid, shard): - if not self.is_valid(uid, shard): return False - try: - mw.col.conf[SETTINGS.EVERNOTE_ACCOUNT_UID] = uid - mw.col.conf[SETTINGS.EVERNOTE_ACCOUNT_SHARD] = shard - except: - return False - self.uid = uid - self.shard = shard - return self.Valid + uid = SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE + shard = SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE + + @property + def Valid(self): + return self.is_valid() + + def is_valid(self, uid=None, shard=None): + if uid is None: uid = self.uid + if shard is None: shard = self.shard + if not uid or not shard: return False + if uid == '0' or uid == SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE or not unicode(uid).isnumeric(): return False + if shard == 's999' or uid == SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE or shard[0] != 's' or not unicode(shard[1:]).isnumeric(): return False + return True + + def __init__(self, uid=None, shard=None): + if uid and shard: + if self.update(uid, shard): return + try: + self.uid = mw.col.conf.get(SETTINGS.EVERNOTE.ACCOUNT.UID, SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE) + self.shard = mw.col.conf.get(SETTINGS.EVERNOTE.ACCOUNT.SHARD, SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE) + if self.Valid: return + except: + pass + self.uid = SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE + self.shard = SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE + + def update(self, uid, shard): + if not self.is_valid(uid, shard): return False + try: + mw.col.conf[SETTINGS.EVERNOTE.ACCOUNT.UID] = uid + mw.col.conf[SETTINGS.EVERNOTE.ACCOUNT.SHARD] = shard + except: + return False + self.uid = uid + self.shard = shard + return self.Valid diff --git a/anknotes/logging.py b/anknotes/logging.py index c6ca567..0953d4c 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -9,491 +9,492 @@ # Anknotes Shared Imports from anknotes.constants import * from anknotes.graphics import * -from anknotes.stopwatch import clockit +# from anknotes.stopwatch import clockit # Anki Imports try: - from aqt import mw - from aqt.utils import tooltip - from aqt.qt import QMessageBox, QPushButton, QSizePolicy, QSpacerItem, QGridLayout, QLayout + from aqt import mw + from aqt.utils import tooltip + from aqt.qt import QMessageBox, QPushButton, QSizePolicy, QSpacerItem, QGridLayout, QLayout except: - pass + pass def str_safe(strr, prefix=''): - try: - strr = str((prefix + strr.__repr__())) - except: - strr = str((prefix + strr.__repr__().encode('utf8', 'replace'))) - return strr + try: + strr = str((prefix + strr.__repr__())) + except: + strr = str((prefix + strr.__repr__().encode('utf8', 'replace'))) + return strr def print_safe(strr, prefix=''): - print str_safe(strr, prefix) + print str_safe(strr, prefix) def show_tooltip(text, time_out=7000, delay=None): - if delay: - try: - return mw.progress.timer(delay, lambda: tooltip(text, time_out), False) - except: - pass - tooltip(text, time_out) + if delay: + try: + return mw.progress.timer(delay, lambda: tooltip(text, time_out), False) + except: + pass + tooltip(text, time_out) def counts_as_str(count, max=None): - if max is None: return pad_center(count, 3) - if count == max: return "All %s" % (pad_center(count, 3)) - return "Total %s of %s" % (pad_center(count, 3), pad_center(max, 3)) - -def show_report(title, header, log_lines=None, delay=None, log_header_prefix = ' '*5): - if log_lines is None: log_lines = [] - lines = [] - for line in ('<BR>'.join(header) if isinstance(header, list) else header).split('<BR>') + ('<BR>'.join(log_lines).split('<BR>') if log_lines else []): - level = 0 - while line and line[level] is '-': level += 1 - lines.append('\t'*level + ('\t\t- ' if lines else '') + line[level:]) - if len(lines) > 1: lines[0] += ': ' - log_text = '<BR>'.join(lines) - show_tooltip(log_text.replace('\t', '  '), delay=delay) - log_blank() - log(title) - log(" " + "-" * 192 + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True) - log_blank() + if max is None: return str(count).center(3) + if count == max: return "All %s" % str(count).center(3) + return "Total %s of %s" % (str(count).center(3), str(max).center(3)) + +def show_report(title, header=None, log_lines=None, delay=None, log_header_prefix = ' '*5): + if log_lines is None: log_lines = [] + if header is None: header = [] + lines = [] + for line in ('<BR>'.join(header) if isinstance(header, list) else header).split('<BR>') + ('<BR>'.join(log_lines).split('<BR>') if log_lines else []): + level = 0 + while line and line[level] is '-': level += 1 + lines.append('\t'*level + ('\t\t- ' if lines else '') + line[level:]) + if len(lines) > 1: lines[0] += ': ' + log_text = '<BR>'.join(lines) + show_tooltip(log_text.replace('\t', '  '), delay=delay) + log_blank() + log(title) + if len(lines) == 1 and not lines[0]: + log(" " + "-" * 187, timestamp=False) + else: + log(" " + "-" * 187 + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True) + log_blank() def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0, cancelButton=False, richText=False, minHeight=None, minWidth=400, styleSheet=None, convertNewLines=True): - global imgEvernoteWebMsgBox, icoEvernoteArtcore, icoEvernoteWeb - msgDefaultButton = QPushButton(icoEvernoteArtcore, "Okay!", mw) - msgCancelButton = QPushButton(icoTomato, "No Thanks", mw) - if not styleSheet: - styleSheet = file(ANKNOTES.QT_CSS_QMESSAGEBOX, 'r').read() - - if not isinstance(message, str) and not isinstance(message, unicode): - message = str(message) - - if richText: - textFormat = 1 - # message = message.replace('\n', '<BR>\n') - message = '<style>\n%s</style>\n\n%s' % (styleSheet, message) - global messageBox - messageBox = QMessageBox() - messageBox.addButton(msgDefaultButton, QMessageBox.AcceptRole) - if cancelButton: - messageBox.addButton(msgCancelButton, QMessageBox.RejectRole) - messageBox.setDefaultButton(msgDefaultButton) - messageBox.setIconPixmap(imgEvernoteWebMsgBox) - messageBox.setTextFormat(textFormat) - - # message = ' %s %s' % (styleSheet, message) - log_plain(message, 'showInfo', clear=True) - messageBox.setWindowIcon(icoEvernoteWeb) - messageBox.setWindowIconText("Anknotes") - messageBox.setText(message) - messageBox.setWindowTitle(title) - # if minHeight: - # messageBox.setMinimumHeight(minHeight) - # messageBox.setMinimumWidth(minWidth) - # - # messageBox.setFixedWidth(1000) - hSpacer = QSpacerItem(minWidth, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) - - layout = messageBox.layout() - """:type : QGridLayout """ - # layout.addItem(hSpacer, layout.rowCount() + 1, 0, 1, layout.columnCount()) - layout.addItem(hSpacer, layout.rowCount() + 1, 0, 1, layout.columnCount()) - # messageBox.setStyleSheet(styleSheet) - - - ret = messageBox.exec_() - if not cancelButton: - return True - if messageBox.clickedButton() == msgCancelButton or messageBox.clickedButton() == 0: - return False - return True + global imgEvernoteWebMsgBox, icoEvernoteArtcore, icoEvernoteWeb + msgDefaultButton = QPushButton(icoEvernoteArtcore, "Okay!", mw) + msgCancelButton = QPushButton(icoTomato, "No Thanks", mw) + if not styleSheet: + styleSheet = file(FILES.ANCILLARY.CSS_QMESSAGEBOX, 'r').read() + + if not isinstance(message, str) and not isinstance(message, unicode): + message = str(message) + + if richText: + textFormat = 1 + # message = message.replace('\n', '<BR>\n') + message = '<style>\n%s</style>\n\n%s' % (styleSheet, message) + global messageBox + messageBox = QMessageBox() + messageBox.addButton(msgDefaultButton, QMessageBox.AcceptRole) + if cancelButton: + messageBox.addButton(msgCancelButton, QMessageBox.RejectRole) + messageBox.setDefaultButton(msgDefaultButton) + messageBox.setIconPixmap(imgEvernoteWebMsgBox) + messageBox.setTextFormat(textFormat) + + # message = ' %s %s' % (styleSheet, message) + log_plain(message, 'showInfo', clear=True) + messageBox.setWindowIcon(icoEvernoteWeb) + messageBox.setWindowIconText("Anknotes") + messageBox.setText(message) + messageBox.setWindowTitle(title) + # if minHeight: + # messageBox.setMinimumHeight(minHeight) + # messageBox.setMinimumWidth(minWidth) + # + # messageBox.setFixedWidth(1000) + hSpacer = QSpacerItem(minWidth, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) + + layout = messageBox.layout() + """:type : QGridLayout """ + # layout.addItem(hSpacer, layout.rowCount() + 1, 0, 1, layout.columnCount()) + layout.addItem(hSpacer, layout.rowCount() + 1, 0, 1, layout.columnCount()) + # messageBox.setStyleSheet(styleSheet) + + + ret = messageBox.exec_() + if not cancelButton: + return True + if messageBox.clickedButton() == msgCancelButton or messageBox.clickedButton() == 0: + return False + return True def diffify(content, split=True): - for tag in [u'div', u'ol', u'ul', u'li', u'span']: - content = content.replace(u"<" + tag, u"\n<" + tag).replace(u"</%s>" % tag, u"</%s>\n" % tag) - content = re.sub(r'[\r\n]+', u'\n', content) - return content.splitlines() if split else content - -def pad_center(val, length=20, favor_right=True): - val = str(val) - pad = max(length - len(val), 0) - pads = [int(round(float(pad) / 2))]*2 - if sum(pads) > pad: pads[favor_right] -= 1 - return ' ' * pads[0] + val + ' ' * pads[1] + for tag in [u'div', u'ol', u'ul', u'li', u'span']: + content = content.replace(u"<" + tag, u"\n<" + tag).replace(u"</%s>" % tag, u"</%s>\n" % tag) + content = re.sub(r'[\r\n]+', u'\n', content) + return content.splitlines() if split else content def generate_diff(value_original, value): - try: - return '\n'.join(list(difflib.unified_diff(diffify(value_original), diffify(value), lineterm=''))) - except: - pass - try: - return '\n'.join( - list(difflib.unified_diff(diffify(value_original.decode('utf-8')), diffify(value), lineterm=''))) - except: - pass - try: - return '\n'.join( - list(difflib.unified_diff(diffify(value_original), diffify(value.decode('utf-8')), lineterm=''))) - except: - pass - try: - return '\n'.join(list( - difflib.unified_diff(diffify(value_original.decode('utf-8')), diffify(value.decode('utf-8')), lineterm=''))) - except: - raise + try: + return '\n'.join(list(difflib.unified_diff(diffify(value_original), diffify(value), lineterm=''))) + except: + pass + try: + return '\n'.join( + list(difflib.unified_diff(diffify(value_original.decode('utf-8')), diffify(value), lineterm=''))) + except: + pass + try: + return '\n'.join( + list(difflib.unified_diff(diffify(value_original), diffify(value.decode('utf-8')), lineterm=''))) + except: + pass + try: + return '\n'.join(list( + difflib.unified_diff(diffify(value_original.decode('utf-8')), diffify(value.decode('utf-8')), lineterm=''))) + except: + raise def obj2log_simple(content): - if not isinstance(content, str) and not isinstance(content, unicode): - content = str(content) - return content + if not isinstance(content, str) and not isinstance(content, unicode): + content = str(content) + return content def convert_filename_to_local_link(filename): - return 'file:///' + filename.replace("\\", "//") + return 'file:///' + filename.replace("\\", "//") class Logger(object): - base_path = None - caller_info=None - default_filename=None - def wrap_filename(self, filename=None): - if filename is None: filename = self.default_filename - if self.base_path is not None: - filename = os.path.join(self.base_path, filename if filename else '') - return filename - - def dump(self, obj, title='', filename=None, *args, **kwargs): - filename = self.wrap_filename(filename) - log_dump(obj=obj, title=title, filename=filename, *args, **kwargs) - - def blank(self, filename=None, *args, **kwargs): - filename = self.wrap_filename(filename) - log_blank(filename=filename, *args, **kwargs) - - def banner(self, title, filename=None, *args, **kwargs): - filename = self.wrap_filename(filename) - log_banner(title=title, filename=filename, *args, **kwargs) - - def go(self, content=None, filename=None, wrap_filename=True, *args, **kwargs): - if wrap_filename: filename = self.wrap_filename(filename) - log(content=content, filename=filename, *args, **kwargs) - - def plain(self, content=None, filename=None, *args, **kwargs): - filename=self.wrap_filename(filename) - log_plain(content=content, filename=filename, *args, **kwargs) - - log = do = add = go - - def default(self, *args, **kwargs): - self.log(wrap_filename=False, *args, **kwargs) - - def __init__(self, base_path=None, default_filename=None, rm_path=False): - self.default_filename = default_filename - if base_path: - self.base_path = base_path - else: - self.caller_info = caller_name() - if self.caller_info: - self.base_path = self.caller_info.Base.replace('.', '\\') - if rm_path: - rm_log_path(self.base_path) - - - + base_path = None + caller_info=None + default_filename=None + def wrap_filename(self, filename=None): + if filename is None: filename = self.default_filename + if self.base_path is not None: + filename = os.path.join(self.base_path, filename if filename else '') + return filename + + def dump(self, obj, title='', filename=None, *args, **kwargs): + filename = self.wrap_filename(filename) + log_dump(obj=obj, title=title, filename=filename, *args, **kwargs) + + def blank(self, filename=None, *args, **kwargs): + filename = self.wrap_filename(filename) + log_blank(filename=filename, *args, **kwargs) + + def banner(self, title, filename=None, *args, **kwargs): + filename = self.wrap_filename(filename) + log_banner(title=title, filename=filename, *args, **kwargs) + + def go(self, content=None, filename=None, wrap_filename=True, *args, **kwargs): + if wrap_filename: filename = self.wrap_filename(filename) + log(content=content, filename=filename, *args, **kwargs) + + def plain(self, content=None, filename=None, *args, **kwargs): + filename=self.wrap_filename(filename) + log_plain(content=content, filename=filename, *args, **kwargs) + + log = do = add = go + + def default(self, *args, **kwargs): + self.log(wrap_filename=False, *args, **kwargs) + + def __init__(self, base_path=None, default_filename=None, rm_path=False): + self.default_filename = default_filename + if base_path: + self.base_path = base_path + else: + self.caller_info = caller_name() + if self.caller_info: + self.base_path = self.caller_info.Base.replace('.', '\\') + if rm_path: + rm_log_path(self.base_path) + + + def log_blank(filename=None, *args, **kwargs): - log(timestamp=False, content=None, filename=filename, *args, **kwargs) + log(timestamp=False, content=None, filename=filename, *args, **kwargs) def log_plain(*args, **kwargs): - log(timestamp=False, *args, **kwargs) - + log(timestamp=False, *args, **kwargs) + def rm_log_path(filename='*', subfolders_only=False, retry_errors=0): - path = os.path.dirname(os.path.abspath(get_log_full_path(filename))) - if path is ANKNOTES.FOLDER_LOGS or path in ANKNOTES.FOLDER_LOGS: return - rm_log_path.errors = [] - def del_subfolder(arg=None,dirname=None,filenames=None, is_subfolder=True): - def rmtree_error(f, p, e): - rm_log_path.errors += [p] - if is_subfolder and dirname is path: return - shutil.rmtree(dirname, onerror=rmtree_error) - if not subfolders_only: del_subfolder(dirname=path, is_subfolder=False) - else: os.path.walk(path, del_subfolder, None) - if rm_log_path.errors: - if retry_errors > 5: - print "Unable to delete log path" - log("Unable to delete log path as requested", filename) - return - time.sleep(1) - rm_log_path(filename, subfolders_only, retry_errors + 1) + path = os.path.dirname(os.path.abspath(get_log_full_path(filename))) + if path is FOLDERS.LOGS or path in FOLDERS.LOGS: return + rm_log_path.errors = [] + def del_subfolder(arg=None,dirname=None,filenames=None, is_subfolder=True): + def rmtree_error(f, p, e): + rm_log_path.errors += [p] + if is_subfolder and dirname is path: return + shutil.rmtree(dirname, onerror=rmtree_error) + if not subfolders_only: del_subfolder(dirname=path, is_subfolder=False) + else: os.path.walk(path, del_subfolder, None) + if rm_log_path.errors: + if retry_errors > 5: + print "Unable to delete log path" + log("Unable to delete log path as requested", filename) + return + time.sleep(1) + rm_log_path(filename, subfolders_only, retry_errors + 1) def log_banner(title, filename, length=80, append_newline=True, *args, **kwargs): - log("-" * length, filename, clear=True, timestamp=False, *args, **kwargs) - log(pad_center(title, length),filename, timestamp=False, *args, **kwargs) - log("-" * length, filename, timestamp=False, *args, **kwargs) - if append_newline: log_blank(filename, *args, **kwargs) + log("-" * length, filename, clear=True, timestamp=False, *args, **kwargs) + log(title.center(length),filename, timestamp=False, *args, **kwargs) + log("-" * length, filename, timestamp=False, *args, **kwargs) + if append_newline: log_blank(filename, *args, **kwargs) _log_filename_history = [] def set_current_log(fn): - global _log_filename_history - _log_filename_history.append(fn) + global _log_filename_history + _log_filename_history.append(fn) def end_current_log(fn=None): - global _log_filename_history - if fn: - _log_filename_history.remove(fn) - else: - _log_filename_history = _log_filename_history[:-1] - -def get_log_full_path(filename=None, extension='log', as_url_link=False): - global _log_filename_history - log_base_name = ANKNOTES.LOG_BASE_NAME - filename_suffix = '' - if filename and filename[0] == '*': - filename_suffix = '\\' + filename[1:] - log_base_name = '' - filename = None - if filename is None: - if ANKNOTES.LOG_USE_CALLER_NAME: - caller = caller_name() - if caller: - filename = caller.Base.replace('.', '\\') - if filename is None: - filename = _log_filename_history[-1] if _log_filename_history else ANKNOTES.LOG_ACTIVE - if not filename: - filename = log_base_name - if not filename: filename = ANKNOTES.LOG_DEFAULT_NAME - else: - if filename[0] is '+': - filename = filename[1:] - filename = (log_base_name + '-' if log_base_name and log_base_name[-1] != '\\' else '') + filename - - filename += filename_suffix - filename += ('.' if filename and filename[-1] is not '.' else '') + extension - filename = re.sub(r'[^\w\-_\.\\]', '_', filename) - full_path = os.path.join(ANKNOTES.FOLDER_LOGS, filename) - if not os.path.exists(os.path.dirname(full_path)): - os.makedirs(os.path.dirname(full_path)) - if as_url_link: return convert_filename_to_local_link(full_path) - return full_path + global _log_filename_history + if fn: + _log_filename_history.remove(fn) + else: + _log_filename_history = _log_filename_history[:-1] + +def get_log_full_path(filename=None, extension='log', as_url_link=False, prefix=''): + global _log_filename_history + log_base_name = FILES.LOGS.BASE_NAME + filename_suffix = '' + if filename and filename[0] == '*': + filename_suffix = '\\' + filename[1:] + log_base_name = '' + filename = None + if filename is None: + if FILES.LOGS.USE_CALLER_NAME: + caller = caller_name() + if caller: + filename = caller.Base.replace('.', '\\') + if filename is None: + filename = _log_filename_history[-1] if _log_filename_history else FILES.LOGS.ACTIVE + if not filename: + filename = log_base_name + if not filename: filename = FILES.LOGS.DEFAULT_NAME + else: + if filename[0] is '+': + filename = filename[1:] + filename = (log_base_name + '-' if log_base_name and log_base_name[-1] != '\\' else '') + filename + + filename += filename_suffix + filename += ('.' if filename and filename[-1] is not '.' else '') + extension + filename = re.sub(r'[^\w\-_\.\\]', '_', filename) + full_path = os.path.join(FOLDERS.LOGS, filename) + if prefix: + parent, fn = os.path.split(full_path) + if fn != '.' + extension: fn = '-' + fn + full_path = os.path.join(parent, prefix + fn) + if not os.path.exists(os.path.dirname(full_path)): + os.makedirs(os.path.dirname(full_path)) + if as_url_link: return convert_filename_to_local_link(full_path) + return full_path def encode_log_text(content): - if not isinstance(content, str) and not isinstance(content, unicode): return content - try: - return content.encode('utf-8') - except Exception: - return content - + if not isinstance(content, str) and not isinstance(content, unicode): return content + try: + return content.encode('utf-8') + except Exception: + return content + # @clockit def log(content=None, filename=None, prefix='', clear=False, timestamp=True, extension='log', - replace_newline=None, do_print=False, encode_text=True): - if content is None: content = '' - else: - content = obj2log_simple(content) - if len(content) == 0: content = '{EMPTY STRING}' - if content[0] == "!": - content = content[1:] - prefix = '\n' - if filename and filename[0] is '+': - summary = " ** CROSS-POST TO %s: " % filename[1:] + content - log(summary[:200]) - full_path = get_log_full_path(filename, extension) - st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' - if timestamp or replace_newline is True: - try: content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content) - except UnicodeDecodeError: - content = content.decode('utf-8') - content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content) - contents = prefix + ' ' + st + content - if encode_text: content = encode_log_text(content) - with open(full_path, 'w+' if clear else 'a+') as fileLog: - try: print>> fileLog, contents - except UnicodeEncodeError: - contents = contents.encode('utf-8') - print>> fileLog, contents - if do_print: print contents + replace_newline=None, do_print=False, encode_text=True, print_timestamp=False): + if content is None: content = '' + else: + content = obj2log_simple(content) + if len(content) == 0: content = '{EMPTY STRING}' + if content[0] == "!": + content = content[1:] + prefix = '\n' + if filename and filename[0] is '+': + summary = " ** CROSS-POST TO %s: " % filename[1:] + content + log(summary[:200]) + full_path = get_log_full_path(filename, extension) + st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' + if timestamp or replace_newline is True: + try: content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content) + except UnicodeDecodeError: + content = content.decode('utf-8') + content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content) + if encode_text: content = encode_log_text(content) + contents = prefix + ' ' + st + content + with open(full_path, 'w+' if clear else 'a+') as fileLog: + try: print>> fileLog, contents + except UnicodeEncodeError: + contents = contents.encode('utf-8') + print>> fileLog, contents + if do_print: print contents if print_timestamp else content def log_sql(content, **kwargs): - log(content, 'sql', **kwargs) + log(content, 'sql', **kwargs) def log_error(content, crossPost=True, **kwargs): - log(content, ('+' if crossPost else '') + 'error', **kwargs) + log(content, ('+' if crossPost else '') + 'error', **kwargs) def print_dump(obj): - content = pprint.pformat(obj, indent=4, width=80) - content = content.replace(', ', ', \n ') - content = content.replace('\r', '\r ').replace('\n', - '\n ') - content = encode_log_text(content) - print content - return content - -def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, extension='log'): - content = pprint.pformat(obj, indent=4, width=80) - try: content = content.decode('utf-8', 'ignore') - except Exception: pass - content = content.replace("\\n", '\n').replace('\\r', '\r') - if filename and filename[0] is '+': - summary = " ** CROSS-POST TO %s: " % filename[1:] + content - log(summary[:200]) - filename = 'dump' + ('-%s' % filename if filename else '') - full_path = get_log_full_path(filename, extension) - st = '' - if timestamp: - st = datetime.now().strftime(ANKNOTES.DATE_FORMAT) - st = '[%s]: ' % st - - if title[0] == '-': - prefix = " **** Dumping %s" % title[1:] - else: - prefix = " **** Dumping %s" % title - log(prefix) - - content = encode_log_text(content) - - try: - prefix += '\r\n' - content = prefix + content.replace(', ', ', \n ') - content = content.replace("': {", "': {\n ") - content = content.replace('\r', '\r ').replace('\n', - '\n ') - except: - pass - - if not os.path.exists(os.path.dirname(full_path)): - os.makedirs(os.path.dirname(full_path)) - with open(full_path, 'w+' if clear else 'a+') as fileLog: - try: - print>> fileLog, (u'\n %s%s' % (st, content)) - return - except: - pass - try: - print>> fileLog, (u'\n <1> %s%s' % (st, content.decode('utf-8'))) - return - except: - pass - try: - print>> fileLog, (u'\n <2> %s%s' % (st, content.encode('utf-8'))) - return - except: - pass - try: - print>> fileLog, ('\n <3> %s%s' % (st, content.decode('utf-8'))) - return - except: - pass - try: - print>> fileLog, ('\n <4> %s%s' % (st, content.encode('utf-8'))) - return - except: - pass - try: - print>> fileLog, (u'\n <5> %s%s' % (st, "Error printing content: " + str_safe(content))) - return - except: - pass - print>> fileLog, (u'\n <6> %s%s' % (st, "Error printing content: " + content[:10])) + content = pprint.pformat(obj, indent=4, width=80) + content = content.replace(', ', ', \n ') + content = content.replace('\r', '\r ').replace('\n', + '\n ') + content = encode_log_text(content) + print content + return content + +def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, extension='log', crosspost_to_default=True): + content = pprint.pformat(obj, indent=4, width=80) + try: content = content.decode('utf-8', 'ignore') + except Exception: pass + content = content.replace("\\n", '\n').replace('\\r', '\r') + if filename and filename[0] is '+': + summary = " ** CROSS-POST TO %s: " % filename[1:] + content + log(summary[:200]) + # filename = 'dump' + ('-%s' % filename if filename else '') + full_path = get_log_full_path(filename, extension, prefix='dump') + st = '' + if timestamp: + st = datetime.now().strftime(ANKNOTES.DATE_FORMAT) + st = '[%s]: ' % st + + if title[0] == '-': + prefix = " **** Dumping %s" % title[1:] + else: + prefix = " **** Dumping %s" % title + if crosspost_to_default: log(prefix) + + content = encode_log_text(content) + + try: + prefix += '\r\n' + content = prefix + content.replace(', ', ', \n ') + content = content.replace("': {", "': {\n ") + content = content.replace('\r', '\r ').replace('\n', + '\n ') + except: + pass + + if not os.path.exists(os.path.dirname(full_path)): + os.makedirs(os.path.dirname(full_path)) + with open(full_path, 'w+' if clear else 'a+') as fileLog: + try: + print>> fileLog, (u'\n %s%s' % (st, content)) + return + except: + pass + try: + print>> fileLog, (u'\n <1> %s%s' % (st, content.decode('utf-8'))) + return + except: + pass + try: + print>> fileLog, (u'\n <2> %s%s' % (st, content.encode('utf-8'))) + return + except: + pass + try: + print>> fileLog, ('\n <3> %s%s' % (st, content.decode('utf-8'))) + return + except: + pass + try: + print>> fileLog, ('\n <4> %s%s' % (st, content.encode('utf-8'))) + return + except: + pass + try: + print>> fileLog, (u'\n <5> %s%s' % (st, "Error printing content: " + str_safe(content))) + return + except: + pass + print>> fileLog, (u'\n <6> %s%s' % (st, "Error printing content: " + content[:10])) def log_api(method, content='', **kwargs): - if content: content = ': ' + content - log(" API_CALL [%3d]: %10s%s" % (get_api_call_count(), method, content), 'api', **kwargs) + if content: content = ': ' + content + log(" API_CALL [%3d]: %10s%s" % (get_api_call_count(), method, content), 'api', **kwargs) def get_api_call_count(): - path = get_log_full_path('api') - if not os.path.exists(path): return 0 - api_log = file(path, 'r').read().splitlines() - count = 1 - for i in range(len(api_log), 0, -1): - call = api_log[i - 1] - if not "API_CALL" in call: - continue - ts = call.replace(':\t', ': ').split(': ')[0][2:-1] - td = datetime.now() - datetime.strptime(ts, ANKNOTES.DATE_FORMAT) - if td < timedelta(hours=1): - count += 1 - else: - return count - return count + path = get_log_full_path('api') + if not os.path.exists(path): return 0 + api_log = file(path, 'r').read().splitlines() + count = 1 + for i in range(len(api_log), 0, -1): + call = api_log[i - 1] + if not "API_CALL" in call: + continue + ts = call.replace(':\t', ': ').split(': ')[0][2:-1] + td = datetime.now() - datetime.strptime(ts, ANKNOTES.DATE_FORMAT) + if td < timedelta(hours=1): + count += 1 + else: + return count + return count def caller_names(return_string=True, simplify=True): - return [c.Base if return_string else c for c in [__caller_name__(i,simplify) for i in range(0,20)] if c and c.Base] + return [c.Base if return_string else c for c in [__caller_name__(i,simplify) for i in range(0,20)] if c and c.Base] class CallerInfo: - Class=[] - Module=[] - Outer=[] - Name="" - simplify=True - __keywords_exclude__=['pydevd', 'logging', 'stopwatch'] - __keywords_strip__=['__maxin__', 'anknotes', '<module>'] - __outer__ = [] - filtered=True - @property - def __trace__(self): - return self.Module + self.Outer + self.Class + [self.Name] - - @property - def Trace(self): - t= self._strip_(self.__trace__) - return t if not self.filtered or not [e for e in self.__keywords_exclude__ if e in t] else [] - - @property - def Base(self): - return '.'.join(self._strip_(self.Module + self.Class + [self.Name])) if self.Trace else '' - - @property - def Full(self): - return '.'.join(self.Trace) - - def _strip_(self, lst): - return [t for t in lst if t and t not in self.__keywords_strip__] - - def __init__(self, parentframe=None): - """ - - :rtype : CallerInfo - """ - if not parentframe: return - self.Class = parentframe.f_locals['self'].__class__.__name__.split('.') if 'self' in parentframe.f_locals else [] - module = inspect.getmodule(parentframe) - self.Module = module.__name__.split('.') if module else [] - self.Name = parentframe.f_code.co_name if parentframe.f_code.co_name is not '<module>' else '' - self.__outer__ = [[f[1], f[3]] for f in inspect.getouterframes(parentframe) if f] - self.__outer__.reverse() - self.Outer = [f[1] for f in self.__outer__ if f and f[1] and not [exclude for exclude in self.__keywords_exclude__ + [self.Name] if exclude in f[0] or exclude in f[1]]] - del parentframe - -@clockit + Class=[] + Module=[] + Outer=[] + Name="" + simplify=True + __keywords_exclude__=['pydevd', 'logging', 'stopwatch'] + __keywords_strip__=['__maxin__', 'anknotes', '<module>'] + __outer__ = [] + filtered=True + @property + def __trace__(self): + return self.Module + self.Outer + self.Class + [self.Name] + + @property + def Trace(self): + t= self._strip_(self.__trace__) + return t if not self.filtered or not [e for e in self.__keywords_exclude__ if e in t] else [] + + @property + def Base(self): + return '.'.join(self._strip_(self.Module + self.Class + [self.Name])) if self.Trace else '' + + @property + def Full(self): + return '.'.join(self.Trace) + + def _strip_(self, lst): + return [t for t in lst if t and t not in self.__keywords_strip__] + + def __init__(self, parentframe=None): + """ + + :rtype : CallerInfo + """ + if not parentframe: return + self.Class = parentframe.f_locals['self'].__class__.__name__.split('.') if 'self' in parentframe.f_locals else [] + module = inspect.getmodule(parentframe) + self.Module = module.__name__.split('.') if module else [] + self.Name = parentframe.f_code.co_name if parentframe.f_code.co_name is not '<module>' else '' + self.__outer__ = [[f[1], f[3]] for f in inspect.getouterframes(parentframe) if f] + self.__outer__.reverse() + self.Outer = [f[1] for f in self.__outer__ if f and f[1] and not [exclude for exclude in self.__keywords_exclude__ + [self.Name] if exclude in f[0] or exclude in f[1]]] + del parentframe + +# @clockit def caller_name(skip=None, simplify=True, return_string=False): - if skip is None: - for c in [__caller_name__(i,simplify) for i in range(0,20)]: - if c and c.Base: - return c.Base if return_string else c - return None - c = __caller_name__(skip, simplify=simplify) - return c.Base if return_string else c + if skip is None: + for c in [__caller_name__(i,simplify) for i in range(0,20)]: + if c and c.Base: + return c.Base if return_string else c + return None + c = __caller_name__(skip, simplify=simplify) + return c.Base if return_string else c def __caller_name__(skip=0, simplify=True): - """Get a name of a caller in the format module.class.method - - `skip` specifies how many levels of stack to skip while getting caller - name. skip=1 means "who calls me", skip=2 "who calls my caller" etc. - - An empty string is returned if skipped levels exceed stack height - :rtype : CallerInfo - """ - stack = inspect.stack() - start = 0 + skip - if len(stack) < start + 1: - return None - parentframe = stack[start][0] - c_info = CallerInfo(parentframe) - del parentframe - return c_info + """Get a name of a caller in the format module.class.method + + `skip` specifies how many levels of stack to skip while getting caller + name. skip=1 means "who calls me", skip=2 "who calls my caller" etc. + + An empty string is returned if skipped levels exceed stack height + :rtype : CallerInfo + """ + stack = inspect.stack() + start = 0 + skip + if len(stack) < start + 1: + return None + parentframe = stack[start][0] + c_info = CallerInfo(parentframe) + del parentframe + return c_info # log('completed %s' % __name__, 'import') \ No newline at end of file diff --git a/anknotes/menu.py b/anknotes/menu.py index 828a582..f69c3cd 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -3,9 +3,9 @@ from subprocess import * try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite # Anknotes Shared Imports from anknotes.shared import * @@ -26,284 +26,286 @@ # noinspection PyTypeChecker def anknotes_setup_menu(): - menu_items = [ - [u"&Anknotes", - [ - ["&Import from Evernote", import_from_evernote], - ["&Enable Auto Import On Profile Load", {'action': anknotes_menu_auto_import_changed, 'checkable': True}], - ["Note &Validation", - [ - ["Validate &And Upload Pending Notes", validate_pending_notes], - ["SEPARATOR", None], - ["&Validate Pending Notes", lambda: validate_pending_notes(True, False)], - ["&Upload Validated Notes", upload_validated_notes] - ] - ], - ["Process &See Also Footer Links [Power Users Only!]", - [ - ["Complete All &Steps", see_also], - ["SEPARATOR", None], - ["Step &1: Process Anki Notes Without See Also Field", lambda: see_also(1)], - ["Step &2: Extract Links from TOC", lambda: see_also(2)], - ["SEPARATOR", None], - ["Step &3: Create Auto TOC Evernote Notes", lambda: see_also(3)], - ["Step &4: Validate and Upload Auto TOC Notes", lambda: see_also(4)], - ["Step &5: Rebuild TOC/Outline Link Database", lambda: see_also(5)], - ["SEPARATOR", None], - ["Step &6: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(6)], - ["Step &7: Update See Also Footer In Evernote Notes", lambda: see_also(7)], - ["Step &8: Validate and Upload Modified Evernote Notes", lambda: see_also(8)], - ["SEPARATOR", None], - ["Step &9: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(9)] - ] - ], - ["&Maintenance Tasks", - [ - ["Find &Deleted Notes", find_deleted_notes], - ["Res&ync with Local DB", resync_with_local_db], - ["Update Evernote &Ancillary Data", update_ancillary_data] - ] - ] - - ] - ] - ] - add_menu_items(menu_items) + menu_items = [ + [u"&Anknotes", + [ + ["&Import from Evernote", import_from_evernote], + ["&Enable Auto Import On Profile Load", {'action': anknotes_menu_auto_import_changed, 'checkable': True}], + ["Note &Validation", + [ + ["Validate &And Upload Pending Notes", validate_pending_notes], + ["SEPARATOR", None], + ["&Validate Pending Notes", lambda: validate_pending_notes(True, False)], + ["&Upload Validated Notes", upload_validated_notes] + ] + ], + ["Process &See Also Footer Links [Power Users Only!]", + [ + ["Complete All &Steps", see_also], + ["SEPARATOR", None], + ["Step &1: Process Anki Notes Without See Also Field", lambda: see_also(1)], + ["Step &2: Extract Links from TOC", lambda: see_also(2)], + ["SEPARATOR", None], + ["Step &3: Create Auto TOC Evernote Notes", lambda: see_also(3)], + ["Step &4: Validate and Upload Auto TOC Notes", lambda: see_also(4)], + ["Step &5: Rebuild TOC/Outline Link Database", lambda: see_also(5)], + ["SEPARATOR", None], + ["Step &6: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(6)], + ["Step &7: Update See Also Footer In Evernote Notes", lambda: see_also(7)], + ["Step &8: Validate and Upload Modified Evernote Notes", lambda: see_also(8)], + ["SEPARATOR", None], + ["Step &9: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(9)] + ] + ], + ["&Maintenance Tasks", + [ + ["Find &Deleted Notes", find_deleted_notes], + ["Res&ync with Local DB", resync_with_local_db], + ["Update Evernote &Ancillary Data", update_ancillary_data] + ] + ] + + ] + ] + ] + add_menu_items(menu_items) def add_menu_items(menu_items, parent=None): - if not parent: parent = mw.form.menubar - for title, action in menu_items: - if title == "SEPARATOR": - parent.addSeparator() - elif isinstance(action, list): - menu = QMenu(_(title), parent) - parent.insertMenu(mw.form.menuTools.menuAction(), menu) - add_menu_items(action, menu) - else: - checkable = False - if isinstance(action, dict): - options = action - action = options['action'] - if 'checkable' in options: - checkable = options['checkable'] - menu_action = QAction(_(title), mw, checkable=checkable) - parent.addAction(menu_action) - parent.connect(menu_action, SIGNAL("triggered()"), action) - if checkable: - anknotes_checkable_menu_items[title] = menu_action + if not parent: parent = mw.form.menubar + for title, action in menu_items: + if title == "SEPARATOR": + parent.addSeparator() + elif isinstance(action, list): + menu = QMenu(_(title), parent) + parent.insertMenu(mw.form.menuTools.menuAction(), menu) + add_menu_items(action, menu) + else: + checkable = False + if isinstance(action, dict): + options = action + action = options['action'] + if 'checkable' in options: + checkable = options['checkable'] + menu_action = QAction(_(title), mw, checkable=checkable) + parent.addAction(menu_action) + parent.connect(menu_action, SIGNAL("triggered()"), action) + if checkable: + anknotes_checkable_menu_items[title] = menu_action def anknotes_menu_auto_import_changed(): - title = "&Enable Auto Import On Profile Load" - doAutoImport = anknotes_checkable_menu_items[title].isChecked() - mw.col.conf[ - SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', '')] = doAutoImport - mw.col.setMod() - mw.col.save() - # import_timer_toggle() + title = "&Enable Auto Import On Profile Load" + doAutoImport = anknotes_checkable_menu_items[title].isChecked() + mw.col.conf[ + SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', '')] = doAutoImport + mw.col.setMod() + mw.col.save() + # import_timer_toggle() def anknotes_load_menu_settings(): - global anknotes_checkable_menu_items - for title, menu_action in anknotes_checkable_menu_items.items(): - menu_action.setChecked(mw.col.conf.get( - SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False)) + global anknotes_checkable_menu_items + for title, menu_action in anknotes_checkable_menu_items.items(): + menu_action.setChecked(mw.col.conf.get( + SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False)) def import_from_evernote_manual_metadata(guids=None): - if not guids: - guids = find_evernote_guids(file(ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES, 'r').read()) - log("Manually downloading %d Notes" % len(guids)) - controller = Controller() - controller.forceAutoPage = True - controller.currentPage = 1 - controller.ManualGUIDs = guids - controller.proceed() + if not guids: + guids = find_evernote_guids(file(FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES, 'r').read()) + log("Manually downloading %d Notes" % len(guids)) + controller = Controller() + controller.forceAutoPage = True + controller.currentPage = 1 + controller.ManualGUIDs = guids + controller.proceed() def import_from_evernote(auto_page_callback=None): - controller = Controller() - controller.auto_page_callback = auto_page_callback - if auto_page_callback: - controller.forceAutoPage = True - controller.currentPage = 1 - else: - controller.forceAutoPage = False - controller.currentPage = mw.col.conf.get(SETTINGS.EVERNOTE_PAGINATION_CURRENT_PAGE, 1) - controller.proceed() + controller = Controller() + controller.auto_page_callback = auto_page_callback + if auto_page_callback: + controller.forceAutoPage = True + controller.currentPage = 1 + else: + controller.forceAutoPage = False + controller.currentPage = mw.col.conf.get(SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE, 1) + controller.proceed() def upload_validated_notes(automated=False): - controller = Controller() - controller.upload_validated_notes(automated) + controller = Controller() + controller.upload_validated_notes(automated) def find_deleted_notes(automated=False): - if not automated and False: - showInfo("""In order for this to work, you must create a 'Table of Contents' Note using the Evernote desktop application. Include all notes that you want to sync with Anki. + if not automated and False: + showInfo("""In order for this to work, you must create a 'Table of Contents' Note using the Evernote desktop application. Include all notes that you want to sync with Anki. Export this note to the following path: '%s'. Press Okay to save and close your Anki collection, open the command-line deleted notes detection tool, and then re-open your Anki collection. -Once the command line tool is done running, you will get a summary of the results, and will be prompted to delete Anki Orphan Notes or download Missing Evernote Notes""" % ANKNOTES.TABLE_OF_CONTENTS_ENEX, - richText=True) - - # mw.col.save() - # if not automated: - # mw.unloadCollection() - # else: - # mw.col.close() - # handle = Popen(['python',ANKNOTES.FIND_DELETED_NOTES_SCRIPT], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) - # stdoutdata, stderrdata = handle.communicate() - # err = ("ERROR: {%s}\n\n" % stderrdata) if stderrdata else '' - # stdoutdata = re.sub(' +', ' ', stdoutdata) - from anknotes import find_deleted_notes - returnedData = find_deleted_notes.do_find_deleted_notes() - lines = returnedData['Summary'] - info = tableify_lines(lines, '#|Type|Info') - # info = '<table><tr class=tr0><td class=t1>#</td><td class=t2>Type</td><td class=t3></td></tr>%s</table>' % '\n'.join(lines) - # info = info.replace('\n', '\n<BR>').replace(' ', '    ') - anknotes_dels = returnedData['AnknotesOrphans'] - anknotes_dels_count = len(anknotes_dels) - anki_dels = returnedData['AnkiOrphans'] - anki_dels_count = len(anki_dels) - missing_evernote_notes = returnedData['MissingEvernoteNotes'] - missing_evernote_notes_count = len(missing_evernote_notes) - showInfo(info, richText=True, minWidth=600) - db_changed = False - if anknotes_dels_count > 0: - code = \ - getText("Please enter code 'ANKNOTES_DEL_%d' to delete your orphan Anknotes DB note(s)" % anknotes_dels_count)[ - 0] - if code == 'ANKNOTES_DEL_%d' % anknotes_dels_count: - ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, [[x] for x in anknotes_dels]) - ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", - [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anknotes_dels]) - db_changed = True - show_tooltip("Deleted all %d Orphan Anknotes DB Notes" % anknotes_dels_count, 5000, 3000) - if anki_dels_count > 0: - code = getText("Please enter code 'ANKI_DEL_%d' to delete your orphan Anki note(s)" % anki_dels_count)[0] - if code == 'ANKI_DEL_%d' % anki_dels_count: - ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", - [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anki_dels]) - db_changed = True - show_tooltip("Deleted all %d Orphan Anki Notes" % anki_dels_count, 5000, 3000) - if db_changed: - ankDB().commit() - if missing_evernote_notes_count > 0: - evernote_confirm = "Would you like to import %d missing Evernote Notes?<BR><BR><a href='%s'>Click to view results</a>" % ( - missing_evernote_notes_count, - convert_filename_to_local_link(get_log_full_path(ANKNOTES.LOG_FDN_UNIMPORTED_EVERNOTE_NOTES))) - ret = showInfo(evernote_confirm, cancelButton=True, richText=True) - if ret: - import_from_evernote_manual_metadata(missing_evernote_notes) +Once the command line tool is done running, you will get a summary of the results, and will be prompted to delete Anki Orphan Notes or download Missing Evernote Notes""" % FILES.USER.TABLE_OF_CONTENTS_ENEX, + richText=True) + + # mw.col.save() + # if not automated: + # mw.unloadCollection() + # else: + # mw.col.close() + # handle = Popen(['python',FILES.SCRIPTS.FIND_DELETED_NOTES], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) + # stdoutdata, stderrdata = handle.communicate() + # err = ("ERROR: {%s}\n\n" % stderrdata) if stderrdata else '' + # stdoutdata = re.sub(' +', ' ', stdoutdata) + from anknotes import find_deleted_notes + returnedData = find_deleted_notes.do_find_deleted_notes() + lines = returnedData['Summary'] + info = tableify_lines(lines, '#|Type|Info') + # info = '<table><tr class=tr0><td class=t1>#</td><td class=t2>Type</td><td class=t3></td></tr>%s</table>' % '\n'.join(lines) + # info = info.replace('\n', '\n<BR>').replace(' ', '    ') + anknotes_dels = returnedData['AnknotesOrphans'] + anknotes_dels_count = len(anknotes_dels) + anki_dels = returnedData['AnkiOrphans'] + anki_dels_count = len(anki_dels) + missing_evernote_notes = returnedData['MissingEvernoteNotes'] + missing_evernote_notes_count = len(missing_evernote_notes) + showInfo(info, richText=True, minWidth=600) + db_changed = False + if anknotes_dels_count > 0: + code = \ + getText("Please enter code 'ANKNOTES_DEL_%d' to delete your orphan Anknotes DB note(s)" % anknotes_dels_count)[ + 0] + if code == 'ANKNOTES_DEL_%d' % anknotes_dels_count: + ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, [[x] for x in anknotes_dels]) + ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", + [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anknotes_dels]) + db_changed = True + show_tooltip("Deleted all %d Orphan Anknotes DB Notes" % anknotes_dels_count, 5000, 3000) + if anki_dels_count > 0: + code = getText("Please enter code 'ANKI_DEL_%d' to delete your orphan Anki note(s)" % anki_dels_count)[0] + if code == 'ANKI_DEL_%d' % anki_dels_count: + ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", + [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anki_dels]) + db_changed = True + show_tooltip("Deleted all %d Orphan Anki Notes" % anki_dels_count, 5000, 3000) + if db_changed: + ankDB().commit() + if missing_evernote_notes_count > 0: + evernote_confirm = "Would you like to import %d missing Evernote Notes?<BR><BR><a href='%s'>Click to view results</a>" % ( + missing_evernote_notes_count, + convert_filename_to_local_link(get_log_full_path(FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES))) + ret = showInfo(evernote_confirm, cancelButton=True, richText=True) + if ret: + import_from_evernote_manual_metadata(missing_evernote_notes) def validate_pending_notes(showAlerts=True, uploadAfterValidation=True, callback=None): - mw.unloadCollection() - if showAlerts: - showInfo("""Press Okay to save and close your Anki collection, open the command-line note validation tool, and then re-open your Anki collection.%s + mw.unloadCollection() + if showAlerts: + showInfo("""Press Okay to save and close your Anki collection, open the command-line note validation tool, and then re-open your Anki collection.%s Anki will be unresponsive until the validation tool completes. This will take at least 45 seconds. The tool's output will be displayed upon completion. """ - % ( - ' You will be given the option of uploading successfully validated notes once your Anki collection is reopened.' if uploadAfterValidation else '')) - handle = Popen(['python', ANKNOTES.VALIDATION_SCRIPT], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) - stdoutdata, stderrdata = handle.communicate() - stdoutdata = re.sub(' +', ' ', stdoutdata) - info = ("ERROR: {%s}<HR>" % stderrdata) if stderrdata else '' - allowUpload = True - if showAlerts: - tds = [[str(count), '<a href="%s">VIEW %s VALIDATIONS LOG</a>' % (fn, key.upper())] for key, fn, count in [ - [key, get_log_full_path(key, as_url_link=True), int(re.search(r'CHECKING +(\d{1,3}) +' + key.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1))] - for key in ['Pending', 'Successful', 'Failed']] if count > 0] - if not tds: - show_tooltip("No notes found in the validation queue.") - allowUpload = False - else: - info += tableify_lines(tds, '#|Result') - successful = int(re.search(r'CHECKING +(\d{1,3}) +' + 'Successful'.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1)) - allowUpload = (uploadAfterValidation and successful > 0) - allowUpload = allowUpload & showInfo("Completed: %s<BR>%s" % ( - 'Press Okay to begin uploading %d successfully validated note(s) to the Evernote Servers' % successful if (uploadAfterValidation and successful > 0) else '', - info), cancelButton=(successful > 0), richText=True) - - - # mw.col.reopen() - # mw.col.load() - - if callback is None and allowUpload: - callback = upload_validated_notes - external_tool_callback_timer(callback) + % ( + ' You will be given the option of uploading successfully validated notes once your Anki collection is reopened.' if uploadAfterValidation else '')) + handle = Popen(['python', FILES.SCRIPTS.VALIDATION], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) + stdoutdata, stderrdata = handle.communicate() + stdoutdata = re.sub(' +', ' ', stdoutdata) + info = ("ERROR: {%s}<HR>" % stderrdata) if stderrdata else '' + allowUpload = True + if showAlerts: + tds = [[str(count), '<a href="%s">VIEW %s VALIDATIONS LOG</a>' % (fn, key.upper())] for key, fn, count in [ + [key, get_log_full_path('MakeNoteQueue\\' + key, as_url_link=True), int(re.search(r'CHECKING +(\d{1,3}) +' + key.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1))] + for key in ['Pending', 'Successful', 'Failed']] if count > 0] + if not tds: + show_tooltip("No notes found in the validation queue.") + allowUpload = False + else: + info += tableify_lines(tds, '#|Results') + successful = int(re.search(r'CHECKING +(\d{1,3}) +' + 'Successful'.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1)) + allowUpload = (uploadAfterValidation and successful > 0) + allowUpload = allowUpload & showInfo("Completed: %s<BR>%s" % ( + 'Press Okay to begin uploading %d successfully validated note(s) to the Evernote Servers' % successful if (uploadAfterValidation and successful > 0) else '', + info), cancelButton=(successful > 0), richText=True) + + + # mw.col.reopen() + # mw.col.load() + + if callback is None and allowUpload: + callback = upload_validated_notes + external_tool_callback_timer(callback) def reopen_collection(callback=None): - # mw.setupProfile() - mw.loadCollection() - ankDB(True) - if callback: callback() + # mw.setupProfile() + mw.loadCollection() + ankDB(True) + if callback: callback() def external_tool_callback_timer(callback=None): - mw.progress.timer(3000, lambda: reopen_collection(callback), False) + mw.progress.timer(3000, lambda: reopen_collection(callback), False) def see_also(steps=None, showAlerts=None, validationComplete=False): - controller = Controller() - if not steps: steps = range(1, 10) - if isinstance(steps, int): steps = [steps] - multipleSteps = (len(steps) > 1) - if showAlerts is None: showAlerts = not multipleSteps - remaining_steps=steps - if 1 in steps: - # Should be unnecessary once See Also algorithms are finalized - log(" > See Also: Step 1: Processing Un Added See Also Notes") - controller.process_unadded_see_also_notes() - if 2 in steps: - log(" > See Also: Step 2: Extracting Links from TOC") - controller.anki.extract_links_from_toc() - if 3 in steps: - log(" > See Also: Step 3: Creating Auto TOC Evernote Notes") - controller.create_auto_toc() - if 4 in steps: - if validationComplete: - log(" > See Also: Step 4: Validate and Upload Auto TOC Notes: Upload Validating Notes") - upload_validated_notes(multipleSteps) - else: - steps = [-4] - if 5 in steps: - log(" > See Also: Step 5: Rebuild TOC/Outline Link Database") - controller.anki.extract_links_from_toc() - if 6 in steps: - log(" > See Also: Step 6: Inserting TOC/Outline Links Into Anki Notes' See Also Field") - controller.anki.insert_toc_into_see_also() - if 7 in steps: - log(" > See Also: Step 7: Update See Also Footer In Evernote Notes") - if 8 in steps: - if validationComplete: - log(" > See Also: Step 8: Validate and Upload Modified Notes: Upload Validating Notes") - upload_validated_notes(multipleSteps) - else: - steps = [-8] - if 9 in steps: - log(" > See Also: Step 10: Inserting TOC/Outline Contents Into Anki Notes") - controller.anki.insert_toc_and_outline_contents_into_notes() - - do_validation = steps[0]*-1 - if do_validation>0: - log(" > See Also: Step %d: Validate and Upload %s Notes: Validating Notes" % (do_validation, {4: 'Auto TOC', 8: 'Modified Evernote'}[do_validation])) - remaining_steps = remaining_steps[remaining_steps.index(do_validation)+validationComplete and 1 or 0:] - validate_pending_notes(showAlerts, callback=lambda: see_also(remaining_steps, False, True)) + controller = Controller() + if not steps: steps = range(1, 10) + if isinstance(steps, int): steps = [steps] + multipleSteps = (len(steps) > 1) + if showAlerts is None: showAlerts = not multipleSteps + remaining_steps=steps + if 1 in steps: + # Should be unnecessary once See Also algorithms are finalized + log(" > See Also: Step 1: Processing Un Added See Also Notes") + controller.process_unadded_see_also_notes() + if 2 in steps: + log(" > See Also: Step 2: Extracting Links from TOC") + controller.anki.extract_links_from_toc() + if 3 in steps: + log(" > See Also: Step 3: Creating Auto TOC Evernote Notes") + controller.create_auto_toc() + if 4 in steps: + if validationComplete: + log(" > See Also: Step 4: Validate and Upload Auto TOC Notes: Upload Validating Notes") + upload_validated_notes(multipleSteps) + else: + steps = [-4] + if 5 in steps: + log(" > See Also: Step 5: Rebuild TOC/Outline Link Database") + controller.anki.extract_links_from_toc() + if 6 in steps: + log(" > See Also: Step 6: Inserting TOC/Outline Links Into Anki Notes' See Also Field") + controller.anki.insert_toc_into_see_also() + if 7 in steps: + log(" > See Also: Step 7: Update See Also Footer In Evernote Notes") + from anknotes import detect_see_also_changes + detect_see_also_changes.main() + if 8 in steps: + if validationComplete: + log(" > See Also: Step 8: Validate and Upload Modified Notes: Upload Validating Notes") + upload_validated_notes(multipleSteps) + else: + steps = [-8] + if 9 in steps: + log(" > See Also: Step 9: Inserting TOC/Outline Contents Into Anki Notes") + controller.anki.insert_toc_and_outline_contents_into_notes() + + do_validation = steps[0]*-1 + if do_validation>0: + log(" > See Also: Step %d: Validate and Upload %s Notes: Validating Notes" % (do_validation, {4: 'Auto TOC', 8: 'Modified Evernote'}[do_validation])) + remaining_steps = remaining_steps[remaining_steps.index(do_validation)+validationComplete and 1 or 0:] + validate_pending_notes(showAlerts, callback=lambda: see_also(remaining_steps, False, True)) def update_ancillary_data(): - controller = Controller() - controller.update_ancillary_data() + controller = Controller() + controller.update_ancillary_data() def resync_with_local_db(): - controller = Controller() - controller.resync_with_local_db() + controller = Controller() + controller.resync_with_local_db() anknotes_checkable_menu_items = {} diff --git a/anknotes/settings.py b/anknotes/settings.py index 1be1ffe..f58c15d 100644 --- a/anknotes/settings.py +++ b/anknotes/settings.py @@ -6,742 +6,742 @@ ### Anki Imports try: - import anki - import aqt - from aqt.preferences import Preferences - from aqt.utils import getText, openLink, getOnlyText - from aqt.qt import QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QGroupBox, SIGNAL, QCheckBox, \ - QComboBox, QSpacerItem, QSizePolicy, QWidget, QSpinBox, QFormLayout, QGridLayout, QFrame, QPalette, \ - QRect, QStackedLayout, QDateEdit, QDateTimeEdit, QTimeEdit, QDate, QDateTime, QTime, QPushButton, QIcon, \ - QMessageBox, QPixmap - from aqt import mw + import anki + import aqt + from aqt.preferences import Preferences + from aqt.utils import getText, openLink, getOnlyText + from aqt.qt import QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QGroupBox, SIGNAL, QCheckBox, \ + QComboBox, QSpacerItem, QSizePolicy, QWidget, QSpinBox, QFormLayout, QGridLayout, QFrame, QPalette, \ + QRect, QStackedLayout, QDateEdit, QDateTimeEdit, QTimeEdit, QDate, QDateTime, QTime, QPushButton, QIcon, \ + QMessageBox, QPixmap + from aqt import mw except: - pass + pass class EvernoteQueryLocationValueQSpinBox(QSpinBox): - __prefix = "" + __prefix = "" - def setPrefix(self, text): - self.__prefix = text + def setPrefix(self, text): + self.__prefix = text - def prefix(self): - return self.__prefix + def prefix(self): + return self.__prefix - def valueFromText(self, text): - if text == self.prefix(): - return 0 - return text[len(self.prefix()) + 1:] + def valueFromText(self, text): + if text == self.prefix(): + return 0 + return text[len(self.prefix()) + 1:] - def textFromValue(self, value): - if value == 0: - return self.prefix() - return self.prefix() + "-" + str(value) + def textFromValue(self, value): + if value == 0: + return self.prefix() + return self.prefix() + "-" + str(value) def setup_evernote(self): - global icoEvernoteWeb - global imgEvernoteWeb - global evernote_default_tag - global evernote_query_any - global evernote_query_use_tags - global evernote_query_tags - global evernote_query_use_excluded_tags - global evernote_query_excluded_tags - global evernote_query_use_notebook - global evernote_query_notebook - global evernote_query_use_note_title - global evernote_query_note_title - global evernote_query_use_search_terms - global evernote_query_search_terms - global evernote_query_use_last_updated - global evernote_query_last_updated_type - global evernote_query_last_updated_value_stacked_layout - global evernote_query_last_updated_value_relative_spinner - global evernote_query_last_updated_value_absolute_date - global evernote_query_last_updated_value_absolute_datetime - global evernote_query_last_updated_value_absolute_time - global default_anki_deck - global anki_deck_evernote_notebook_integration - global keep_evernote_tags - global delete_evernote_query_tags - global evernote_pagination_current_page_spinner - global evernote_pagination_auto_paging - - widget = QWidget() - layout = QVBoxLayout() - - - ########################## QUERY ########################## - group = QGroupBox("EVERNOTE SEARCH OPTIONS:") - group.setStyleSheet('QGroupBox{ font-size: 10px; font-weight: bold; color: rgb(105, 170, 53);}') - form = QFormLayout() - - form.addRow(gen_qt_hr()) - - # Evernote Query: Match Any Terms - evernote_query_any = QCheckBox(" Match Any Terms", self) - evernote_query_any.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_ANY, True)) - evernote_query_any.stateChanged.connect(update_evernote_query_any) - evernote_query_any.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - - button_show_generated_evernote_query = QPushButton(icoEvernoteWeb, "Show Full Query", self) - button_show_generated_evernote_query.setAutoDefault(False) - button_show_generated_evernote_query.connect(button_show_generated_evernote_query, - SIGNAL("clicked()"), - handle_show_generated_evernote_query) - - - # Add Form Row for Match Any Terms - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_any) - hbox.addWidget(button_show_generated_evernote_query) - form.addRow("<b>Search Query:</b>", hbox) - - # Evernote Query: Tags - evernote_query_tags = QLineEdit() - evernote_query_tags.setText( - mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE)) - evernote_query_tags.connect(evernote_query_tags, - SIGNAL("textEdited(QString)"), - update_evernote_query_tags) - - # Evernote Query: Use Tags - evernote_query_use_tags = QCheckBox(" ", self) - evernote_query_use_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_TAGS, True)) - evernote_query_use_tags.stateChanged.connect(update_evernote_query_use_tags) - - # Add Form Row for Tags - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_use_tags) - hbox.addWidget(evernote_query_tags) - form.addRow("Tags:", hbox) - - # Evernote Query: Excluded Tags - evernote_query_excluded_tags = QLineEdit() - evernote_query_excluded_tags.setText( - mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_EXCLUDED_TAGS, '')) - evernote_query_excluded_tags.connect(evernote_query_excluded_tags, - SIGNAL("textEdited(QString)"), - update_evernote_query_excluded_tags) - - # Evernote Query: Use Excluded Tags - evernote_query_use_excluded_tags = QCheckBox(" ", self) - evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_EXCLUDED_TAGS, True)) - evernote_query_use_excluded_tags.stateChanged.connect(update_evernote_query_use_excluded_tags) - - # Add Form Row for Excluded Tags - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_use_excluded_tags) - hbox.addWidget(evernote_query_excluded_tags) - form.addRow("Excluded Tags:", hbox) - - # Evernote Query: Search Terms - evernote_query_search_terms = QLineEdit() - evernote_query_search_terms.setText(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_SEARCH_TERMS, "")) - evernote_query_search_terms.connect(evernote_query_search_terms, - SIGNAL("textEdited(QString)"), - update_evernote_query_search_terms) - - # Evernote Query: Use Search Terms - evernote_query_use_search_terms = QCheckBox(" ", self) - evernote_query_use_search_terms.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_SEARCH_TERMS, False)) - evernote_query_use_search_terms.stateChanged.connect(update_evernote_query_use_search_terms) - - # Add Form Row for Search Terms - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_use_search_terms) - hbox.addWidget(evernote_query_search_terms) - form.addRow("Search Terms:", hbox) - - # Evernote Query: Notebook - evernote_query_notebook = QLineEdit() - evernote_query_notebook.setText( - mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_NOTEBOOK, SETTINGS.EVERNOTE_QUERY_NOTEBOOK_DEFAULT_VALUE)) - evernote_query_notebook.connect(evernote_query_notebook, - SIGNAL("textEdited(QString)"), - update_evernote_query_notebook) - - # Evernote Query: Use Notebook - evernote_query_use_notebook = QCheckBox(" ", self) - evernote_query_use_notebook.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTEBOOK, False)) - evernote_query_use_notebook.stateChanged.connect(update_evernote_query_use_notebook) - - # Add Form Row for Notebook - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_use_notebook) - hbox.addWidget(evernote_query_notebook) - form.addRow("Notebook:", hbox) - - # Evernote Query: Note Title - evernote_query_note_title = QLineEdit() - evernote_query_note_title.setText(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_NOTE_TITLE, "")) - evernote_query_note_title.connect(evernote_query_note_title, - SIGNAL("textEdited(QString)"), - update_evernote_query_note_title) - - # Evernote Query: Use Note Title - evernote_query_use_note_title = QCheckBox(" ", self) - evernote_query_use_note_title.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTE_TITLE, False)) - evernote_query_use_note_title.stateChanged.connect(update_evernote_query_use_note_title) - - # Add Form Row for Note Title - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_use_note_title) - hbox.addWidget(evernote_query_note_title) - form.addRow("Note Title:", hbox) - - # Evernote Query: Last Updated Type - evernote_query_last_updated_type = QComboBox() - evernote_query_last_updated_type.setStyleSheet(' QComboBox { color: rgb(45, 79, 201); font-weight: bold; } ') - evernote_query_last_updated_type.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - evernote_query_last_updated_type.addItems([u"Δ Day", u"Δ Week", u"Δ Month", u"Δ Year", "Date", "+ Time"]) - evernote_query_last_updated_type.setCurrentIndex(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_TYPE, - EvernoteQueryLocationType.RelativeDay)) - evernote_query_last_updated_type.activated.connect(update_evernote_query_last_updated_type) - - - # Evernote Query: Last Updated Type: Relative Date - evernote_query_last_updated_value_relative_spinner = EvernoteQueryLocationValueQSpinBox() - evernote_query_last_updated_value_relative_spinner.setVisible(False) - evernote_query_last_updated_value_relative_spinner.setStyleSheet( - " QSpinBox, EvernoteQueryLocationValueQSpinBox { font-weight: bold; color: rgb(173, 0, 0); } ") - evernote_query_last_updated_value_relative_spinner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - evernote_query_last_updated_value_relative_spinner.connect(evernote_query_last_updated_value_relative_spinner, - SIGNAL("valueChanged(int)"), - update_evernote_query_last_updated_value_relative_spinner) - - # Evernote Query: Last Updated Type: Absolute Date - evernote_query_last_updated_value_absolute_date = QDateEdit() - evernote_query_last_updated_value_absolute_date.setDisplayFormat('M/d/yy') - evernote_query_last_updated_value_absolute_date.setCalendarPopup(True) - evernote_query_last_updated_value_absolute_date.setVisible(False) - evernote_query_last_updated_value_absolute_date.setStyleSheet( - "QDateEdit { font-weight: bold; color: rgb(173, 0, 0); } ") - evernote_query_last_updated_value_absolute_date.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - evernote_query_last_updated_value_absolute_date.connect(evernote_query_last_updated_value_absolute_date, - SIGNAL("dateChanged(QDate)"), - update_evernote_query_last_updated_value_absolute_date) - - # Evernote Query: Last Updated Type: Absolute DateTime - evernote_query_last_updated_value_absolute_datetime = QDateTimeEdit() - evernote_query_last_updated_value_absolute_datetime.setDisplayFormat('M/d/yy h:mm AP') - evernote_query_last_updated_value_absolute_datetime.setCalendarPopup(True) - evernote_query_last_updated_value_absolute_datetime.setVisible(False) - evernote_query_last_updated_value_absolute_datetime.setStyleSheet( - "QDateTimeEdit { font-weight: bold; color: rgb(173, 0, 0); } ") - evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - evernote_query_last_updated_value_absolute_datetime.connect(evernote_query_last_updated_value_absolute_datetime, - SIGNAL("dateTimeChanged(QDateTime)"), - update_evernote_query_last_updated_value_absolute_datetime) - - - - # Evernote Query: Last Updated Type: Absolute Time - evernote_query_last_updated_value_absolute_time = QTimeEdit() - evernote_query_last_updated_value_absolute_time.setDisplayFormat('h:mm AP') - evernote_query_last_updated_value_absolute_time.setVisible(False) - evernote_query_last_updated_value_absolute_time.setStyleSheet( - "QTimeEdit { font-weight: bold; color: rgb(143, 0, 30); } ") - evernote_query_last_updated_value_absolute_time.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - evernote_query_last_updated_value_absolute_time.connect(evernote_query_last_updated_value_absolute_time, - SIGNAL("timeChanged(QTime)"), - update_evernote_query_last_updated_value_absolute_time) - - hbox_datetime = QHBoxLayout() - hbox_datetime.addWidget(evernote_query_last_updated_value_absolute_date) - hbox_datetime.addWidget(evernote_query_last_updated_value_absolute_time) - - # Evernote Query: Last Updated Type - evernote_query_last_updated_value_stacked_layout = QStackedLayout() - evernote_query_last_updated_value_stacked_layout.addWidget(evernote_query_last_updated_value_relative_spinner) - evernote_query_last_updated_value_stacked_layout.addItem(hbox_datetime) - - # Evernote Query: Use Last Updated - evernote_query_use_last_updated = QCheckBox(" ", self) - evernote_query_use_last_updated.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - evernote_query_use_last_updated.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_LAST_UPDATED, False)) - evernote_query_use_last_updated.stateChanged.connect(update_evernote_query_use_last_updated) - - # Add Form Row for Last Updated - hbox = QHBoxLayout() - label = QLabel("Last Updated: ") - label.setMinimumWidth(100) - hbox.addWidget(evernote_query_use_last_updated) - hbox.addWidget(evernote_query_last_updated_type) - hbox.addWidget(evernote_query_last_updated_value_relative_spinner) - hbox.addWidget(evernote_query_last_updated_value_absolute_date) - hbox.addWidget(evernote_query_last_updated_value_absolute_time) - form.addRow(label, hbox) - - # Add Horizontal Row Separator - form.addRow(gen_qt_hr()) - - ############################ PAGINATION ########################## - # Evernote Pagination: Current Page - evernote_pagination_current_page_spinner = QSpinBox() - evernote_pagination_current_page_spinner.setStyleSheet("QSpinBox { font-weight: bold; color: rgb(173, 0, 0); } ") - evernote_pagination_current_page_spinner.setPrefix("PAGE: ") - evernote_pagination_current_page_spinner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - evernote_pagination_current_page_spinner.setValue(mw.col.conf.get(SETTINGS.EVERNOTE_PAGINATION_CURRENT_PAGE, 1)) - evernote_pagination_current_page_spinner.connect(evernote_pagination_current_page_spinner, - SIGNAL("valueChanged(int)"), - update_evernote_pagination_current_page_spinner) - - # Evernote Pagination: Auto Paging - evernote_pagination_auto_paging = QCheckBox(" Automate", self) - evernote_pagination_auto_paging.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - evernote_pagination_auto_paging.setFixedWidth(105) - evernote_pagination_auto_paging.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_AUTO_PAGING, True)) - evernote_pagination_auto_paging.stateChanged.connect(update_evernote_pagination_auto_paging) - - hbox = QHBoxLayout() - hbox.addWidget(evernote_pagination_auto_paging) - hbox.addWidget(evernote_pagination_current_page_spinner) - - # Add Form Row for Evernote Pagination - form.addRow("<b>Pagination:</b>", hbox) - - # Add Query Form to Group Box - group.setLayout(form) - - # Add Query Group Box to Main Layout - layout.addWidget(group) - - ########################## DECK ########################## - # label = QLabel("<span style='background-color: #bf0060;'><B><U>ANKI NOTE OPTIONS</U>:</B></span>") - group = QGroupBox("ANKI NOTE OPTIONS:") - group.setStyleSheet('QGroupBox{ font-size: 10px; font-weight: bold; color: rgb(105, 170, 53);}') - form = QFormLayout() - - # Add Horizontal Row Separator - form.addRow(gen_qt_hr()) - - # Default Anki Deck - default_anki_deck = QLineEdit() - default_anki_deck.setText(mw.col.conf.get(SETTINGS.DEFAULT_ANKI_DECK, SETTINGS.DEFAULT_ANKI_DECK_DEFAULT_VALUE)) - default_anki_deck.connect(default_anki_deck, SIGNAL("textEdited(QString)"), update_default_anki_deck) - - # Add Form Row for Default Anki Deck - hbox = QHBoxLayout() - hbox.insertSpacing(0, 33) - hbox.addWidget(default_anki_deck) - label_deck = QLabel("<b>Anki Deck:</b>") - label_deck.setMinimumWidth(100) - form.addRow(label_deck, hbox) - - # Evernote Notebook Integration - anki_deck_evernote_notebook_integration = QCheckBox(" Append Evernote Notebook", self) - anki_deck_evernote_notebook_integration.setChecked( - mw.col.conf.get(SETTINGS.ANKI_DECK_EVERNOTE_NOTEBOOK_INTEGRATION, True)) - anki_deck_evernote_notebook_integration.stateChanged.connect(update_anki_deck_evernote_notebook_integration) - - # Change Visibility of Deck Options - update_anki_deck_visibilities() - - # Add Form Row for Evernote Notebook Integration - label_deck = QLabel("Evernote Notebook:") - label_deck.setMinimumWidth(100) - form.addRow("", anki_deck_evernote_notebook_integration) - - # Add Horizontal Row Separator - form.addRow(gen_qt_hr()) - - ############################ TAGS ########################## - # Keep Evernote Tags - keep_evernote_tags = QCheckBox(" Save To Anki Note", self) - keep_evernote_tags.setChecked( - mw.col.conf.get(SETTINGS.KEEP_EVERNOTE_TAGS, SETTINGS.KEEP_EVERNOTE_TAGS_DEFAULT_VALUE)) - keep_evernote_tags.stateChanged.connect(update_keep_evernote_tags) - - # Evernote Tags: Tags to Delete - evernote_tags_to_delete = QLineEdit() - evernote_tags_to_delete.setText(mw.col.conf.get(SETTINGS.EVERNOTE_TAGS_TO_DELETE, "")) - evernote_tags_to_delete.connect(evernote_tags_to_delete, - SIGNAL("textEdited(QString)"), - update_evernote_tags_to_delete) - - # Delete Tags To Import - delete_evernote_query_tags = QCheckBox(" Also Delete Search Tags", self) - delete_evernote_query_tags.setChecked(mw.col.conf.get(SETTINGS.DELETE_EVERNOTE_TAGS_TO_IMPORT, True)) - delete_evernote_query_tags.stateChanged.connect(update_delete_evernote_query_tags) - - # Add Form Row for Evernote Tag Options - label = QLabel("<b>Evernote Tags:</b>") - label.setMinimumWidth(100) - form.addRow(label, keep_evernote_tags) - hbox = QHBoxLayout() - hbox.insertSpacing(0, 33) - hbox.addWidget(evernote_tags_to_delete) - form.addRow("Tags to Delete:", hbox) - form.addRow(" ", delete_evernote_query_tags) - - # Add Horizontal Row Separator - form.addRow(gen_qt_hr()) - - ############################ NOTE UPDATING ########################## - # Note Update Method - update_existing_notes = QComboBox() - update_existing_notes.setStyleSheet( - ' QComboBox { color: #3b679e; font-weight: bold; } QComboBoxItem { color: #A40F2D; font-weight: bold; } ') - update_existing_notes.addItems(["Ignore Existing Notes", "Update In-Place", - "Delete and Re-Add"]) - update_existing_notes.setCurrentIndex(mw.col.conf.get(SETTINGS.UPDATE_EXISTING_NOTES, - UpdateExistingNotes.UpdateNotesInPlace)) - update_existing_notes.activated.connect(update_update_existing_notes) - - # Add Form Row for Note Update Method - hbox = QHBoxLayout() - hbox.insertSpacing(0, 33) - hbox.addWidget(update_existing_notes) - form.addRow("<b>Note Updating:</b>", hbox) - - # Add Note Update Method Form to Group Box - group.setLayout(form) - - # Add Note Update Method Group Box to Main Layout - layout.addWidget(group) - - # Update Visibilities of Query Options - evernote_query_text_changed() - update_evernote_query_visibilities() - - - # Vertical Spacer - vertical_spacer = QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) - layout.addItem(vertical_spacer) - - # Parent Widget - widget.setLayout(layout) - - # New Tab - self.form.tabWidget.addTab(widget, "Anknotes") + global icoEvernoteWeb + global imgEvernoteWeb + global evernote_default_tag + global evernote_query_any + global evernote_query_use_tags + global evernote_query_tags + global evernote_query_use_excluded_tags + global evernote_query_excluded_tags + global evernote_query_use_notebook + global evernote_query_notebook + global evernote_query_use_note_title + global evernote_query_note_title + global evernote_query_use_search_terms + global evernote_query_search_terms + global evernote_query_use_last_updated + global evernote_query_last_updated_type + global evernote_query_last_updated_value_stacked_layout + global evernote_query_last_updated_value_relative_spinner + global evernote_query_last_updated_value_absolute_date + global evernote_query_last_updated_value_absolute_datetime + global evernote_query_last_updated_value_absolute_time + global default_anki_deck + global anki_deck_evernote_notebook_integration + global keep_evernote_tags + global delete_evernote_query_tags + global evernote_pagination_current_page_spinner + global evernote_pagination_auto_paging + + widget = QWidget() + layout = QVBoxLayout() + + + ########################## QUERY ########################## + group = QGroupBox("EVERNOTE SEARCH OPTIONS:") + group.setStyleSheet('QGroupBox{ font-size: 10px; font-weight: bold; color: rgb(105, 170, 53);}') + form = QFormLayout() + + form.addRow(gen_qt_hr()) + + # Evernote Query: Match Any Terms + evernote_query_any = QCheckBox(" Match Any Terms", self) + evernote_query_any.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.ANY, True)) + evernote_query_any.stateChanged.connect(update_evernote_query_any) + evernote_query_any.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + button_show_generated_evernote_query = QPushButton(icoEvernoteWeb, "Show Full Query", self) + button_show_generated_evernote_query.setAutoDefault(False) + button_show_generated_evernote_query.connect(button_show_generated_evernote_query, + SIGNAL("clicked()"), + handle_show_generated_evernote_query) + + + # Add Form Row for Match Any Terms + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_any) + hbox.addWidget(button_show_generated_evernote_query) + form.addRow("<b>Search Query:</b>", hbox) + + # Evernote Query: Tags + evernote_query_tags = QLineEdit() + evernote_query_tags.setText( + mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE)) + evernote_query_tags.connect(evernote_query_tags, + SIGNAL("textEdited(QString)"), + update_evernote_query_tags) + + # Evernote Query: Use Tags + evernote_query_use_tags = QCheckBox(" ", self) + evernote_query_use_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_TAGS, True)) + evernote_query_use_tags.stateChanged.connect(update_evernote_query_use_tags) + + # Add Form Row for Tags + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_tags) + hbox.addWidget(evernote_query_tags) + form.addRow("Tags:", hbox) + + # Evernote Query: Excluded Tags + evernote_query_excluded_tags = QLineEdit() + evernote_query_excluded_tags.setText( + mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS, '')) + evernote_query_excluded_tags.connect(evernote_query_excluded_tags, + SIGNAL("textEdited(QString)"), + update_evernote_query_excluded_tags) + + # Evernote Query: Use Excluded Tags + evernote_query_use_excluded_tags = QCheckBox(" ", self) + evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True)) + evernote_query_use_excluded_tags.stateChanged.connect(update_evernote_query_use_excluded_tags) + + # Add Form Row for Excluded Tags + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_excluded_tags) + hbox.addWidget(evernote_query_excluded_tags) + form.addRow("Excluded Tags:", hbox) + + # Evernote Query: Search Terms + evernote_query_search_terms = QLineEdit() + evernote_query_search_terms.setText(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.SEARCH_TERMS, "")) + evernote_query_search_terms.connect(evernote_query_search_terms, + SIGNAL("textEdited(QString)"), + update_evernote_query_search_terms) + + # Evernote Query: Use Search Terms + evernote_query_use_search_terms = QCheckBox(" ", self) + evernote_query_use_search_terms.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS, False)) + evernote_query_use_search_terms.stateChanged.connect(update_evernote_query_use_search_terms) + + # Add Form Row for Search Terms + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_search_terms) + hbox.addWidget(evernote_query_search_terms) + form.addRow("Search Terms:", hbox) + + # Evernote Query: Notebook + evernote_query_notebook = QLineEdit() + evernote_query_notebook.setText( + mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTEBOOK, SETTINGS.EVERNOTE.QUERY.NOTEBOOK_DEFAULT_VALUE)) + evernote_query_notebook.connect(evernote_query_notebook, + SIGNAL("textEdited(QString)"), + update_evernote_query_notebook) + + # Evernote Query: Use Notebook + evernote_query_use_notebook = QCheckBox(" ", self) + evernote_query_use_notebook.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK, False)) + evernote_query_use_notebook.stateChanged.connect(update_evernote_query_use_notebook) + + # Add Form Row for Notebook + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_notebook) + hbox.addWidget(evernote_query_notebook) + form.addRow("Notebook:", hbox) + + # Evernote Query: Note Title + evernote_query_note_title = QLineEdit() + evernote_query_note_title.setText(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTE_TITLE, "")) + evernote_query_note_title.connect(evernote_query_note_title, + SIGNAL("textEdited(QString)"), + update_evernote_query_note_title) + + # Evernote Query: Use Note Title + evernote_query_use_note_title = QCheckBox(" ", self) + evernote_query_use_note_title.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE, False)) + evernote_query_use_note_title.stateChanged.connect(update_evernote_query_use_note_title) + + # Add Form Row for Note Title + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_note_title) + hbox.addWidget(evernote_query_note_title) + form.addRow("Note Title:", hbox) + + # Evernote Query: Last Updated Type + evernote_query_last_updated_type = QComboBox() + evernote_query_last_updated_type.setStyleSheet(' QComboBox { color: rgb(45, 79, 201); font-weight: bold; } ') + evernote_query_last_updated_type.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + evernote_query_last_updated_type.addItems([u"Δ Day", u"Δ Week", u"Δ Month", u"Δ Year", "Date", "+ Time"]) + evernote_query_last_updated_type.setCurrentIndex(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE, + EvernoteQueryLocationType.RelativeDay)) + evernote_query_last_updated_type.activated.connect(update_evernote_query_last_updated_type) + + + # Evernote Query: Last Updated Type: Relative Date + evernote_query_last_updated_value_relative_spinner = EvernoteQueryLocationValueQSpinBox() + evernote_query_last_updated_value_relative_spinner.setVisible(False) + evernote_query_last_updated_value_relative_spinner.setStyleSheet( + " QSpinBox, EvernoteQueryLocationValueQSpinBox { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_query_last_updated_value_relative_spinner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_relative_spinner.connect(evernote_query_last_updated_value_relative_spinner, + SIGNAL("valueChanged(int)"), + update_evernote_query_last_updated_value_relative_spinner) + + # Evernote Query: Last Updated Type: Absolute Date + evernote_query_last_updated_value_absolute_date = QDateEdit() + evernote_query_last_updated_value_absolute_date.setDisplayFormat('M/d/yy') + evernote_query_last_updated_value_absolute_date.setCalendarPopup(True) + evernote_query_last_updated_value_absolute_date.setVisible(False) + evernote_query_last_updated_value_absolute_date.setStyleSheet( + "QDateEdit { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_query_last_updated_value_absolute_date.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_absolute_date.connect(evernote_query_last_updated_value_absolute_date, + SIGNAL("dateChanged(QDate)"), + update_evernote_query_last_updated_value_absolute_date) + + # Evernote Query: Last Updated Type: Absolute DateTime + evernote_query_last_updated_value_absolute_datetime = QDateTimeEdit() + evernote_query_last_updated_value_absolute_datetime.setDisplayFormat('M/d/yy h:mm AP') + evernote_query_last_updated_value_absolute_datetime.setCalendarPopup(True) + evernote_query_last_updated_value_absolute_datetime.setVisible(False) + evernote_query_last_updated_value_absolute_datetime.setStyleSheet( + "QDateTimeEdit { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_absolute_datetime.connect(evernote_query_last_updated_value_absolute_datetime, + SIGNAL("dateTimeChanged(QDateTime)"), + update_evernote_query_last_updated_value_absolute_datetime) + + + + # Evernote Query: Last Updated Type: Absolute Time + evernote_query_last_updated_value_absolute_time = QTimeEdit() + evernote_query_last_updated_value_absolute_time.setDisplayFormat('h:mm AP') + evernote_query_last_updated_value_absolute_time.setVisible(False) + evernote_query_last_updated_value_absolute_time.setStyleSheet( + "QTimeEdit { font-weight: bold; color: rgb(143, 0, 30); } ") + evernote_query_last_updated_value_absolute_time.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_absolute_time.connect(evernote_query_last_updated_value_absolute_time, + SIGNAL("timeChanged(QTime)"), + update_evernote_query_last_updated_value_absolute_time) + + hbox_datetime = QHBoxLayout() + hbox_datetime.addWidget(evernote_query_last_updated_value_absolute_date) + hbox_datetime.addWidget(evernote_query_last_updated_value_absolute_time) + + # Evernote Query: Last Updated Type + evernote_query_last_updated_value_stacked_layout = QStackedLayout() + evernote_query_last_updated_value_stacked_layout.addWidget(evernote_query_last_updated_value_relative_spinner) + evernote_query_last_updated_value_stacked_layout.addItem(hbox_datetime) + + # Evernote Query: Use Last Updated + evernote_query_use_last_updated = QCheckBox(" ", self) + evernote_query_use_last_updated.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + evernote_query_use_last_updated.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_LAST_UPDATED, False)) + evernote_query_use_last_updated.stateChanged.connect(update_evernote_query_use_last_updated) + + # Add Form Row for Last Updated + hbox = QHBoxLayout() + label = QLabel("Last Updated: ") + label.setMinimumWidth(100) + hbox.addWidget(evernote_query_use_last_updated) + hbox.addWidget(evernote_query_last_updated_type) + hbox.addWidget(evernote_query_last_updated_value_relative_spinner) + hbox.addWidget(evernote_query_last_updated_value_absolute_date) + hbox.addWidget(evernote_query_last_updated_value_absolute_time) + form.addRow(label, hbox) + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + ############################ PAGINATION ########################## + # Evernote Pagination: Current Page + evernote_pagination_current_page_spinner = QSpinBox() + evernote_pagination_current_page_spinner.setStyleSheet("QSpinBox { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_pagination_current_page_spinner.setPrefix("PAGE: ") + evernote_pagination_current_page_spinner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_pagination_current_page_spinner.setValue(mw.col.conf.get(SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE, 1)) + evernote_pagination_current_page_spinner.connect(evernote_pagination_current_page_spinner, + SIGNAL("valueChanged(int)"), + update_evernote_pagination_current_page_spinner) + + # Evernote Pagination: Auto Paging + evernote_pagination_auto_paging = QCheckBox(" Automate", self) + evernote_pagination_auto_paging.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + evernote_pagination_auto_paging.setFixedWidth(105) + evernote_pagination_auto_paging.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.AUTO_PAGING, True)) + evernote_pagination_auto_paging.stateChanged.connect(update_evernote_pagination_auto_paging) + + hbox = QHBoxLayout() + hbox.addWidget(evernote_pagination_auto_paging) + hbox.addWidget(evernote_pagination_current_page_spinner) + + # Add Form Row for Evernote Pagination + form.addRow("<b>Pagination:</b>", hbox) + + # Add Query Form to Group Box + group.setLayout(form) + + # Add Query Group Box to Main Layout + layout.addWidget(group) + + ########################## DECK ########################## + # label = QLabel("<span style='background-color: #bf0060;'><B><U>ANKI NOTE OPTIONS</U>:</B></span>") + group = QGroupBox("ANKI NOTE OPTIONS:") + group.setStyleSheet('QGroupBox{ font-size: 10px; font-weight: bold; color: rgb(105, 170, 53);}') + form = QFormLayout() + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + # Default Anki Deck + default_anki_deck = QLineEdit() + default_anki_deck.setText(mw.col.conf.get(SETTINGS.ANKI.DECKS.BASE, SETTINGS.ANKI.DECKS.BASE_DEFAULT_VALUE)) + default_anki_deck.connect(default_anki_deck, SIGNAL("textEdited(QString)"), update_default_anki_deck) + + # Add Form Row for Default Anki Deck + hbox = QHBoxLayout() + hbox.insertSpacing(0, 33) + hbox.addWidget(default_anki_deck) + label_deck = QLabel("<b>Anki Deck:</b>") + label_deck.setMinimumWidth(100) + form.addRow(label_deck, hbox) + + # Evernote Notebook Integration + anki_deck_evernote_notebook_integration = QCheckBox(" Append Evernote Notebook", self) + anki_deck_evernote_notebook_integration.setChecked( + mw.col.conf.get(SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION, True)) + anki_deck_evernote_notebook_integration.stateChanged.connect(update_anki_deck_evernote_notebook_integration) + + # Change Visibility of Deck Options + update_anki_deck_visibilities() + + # Add Form Row for Evernote Notebook Integration + label_deck = QLabel("Evernote Notebook:") + label_deck.setMinimumWidth(100) + form.addRow("", anki_deck_evernote_notebook_integration) + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + ############################ TAGS ########################## + # Keep Evernote Tags + keep_evernote_tags = QCheckBox(" Save To Anki Note", self) + keep_evernote_tags.setChecked( + mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS., SETTINGS.ANKI.TAGS.KEEP_TAGS._DEFAULT_VALUE)) + keep_evernote_tags.stateChanged.connect(update_keep_evernote_tags) + + # Evernote Tags: Tags to Delete + evernote_tags_to_delete = QLineEdit() + evernote_tags_to_delete.setText(mw.col.conf.get(SETTINGS.TAGS.TO_DELETE, "")) + evernote_tags_to_delete.connect(evernote_tags_to_delete, + SIGNAL("textEdited(QString)"), + update_evernote_tags_to_delete) + + # Delete Tags To Import + delete_evernote_query_tags = QCheckBox(" Also Delete Search Tags", self) + delete_evernote_query_tags.setChecked(mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, True)) + delete_evernote_query_tags.stateChanged.connect(update_delete_evernote_query_tags) + + # Add Form Row for Evernote Tag Options + label = QLabel("<b>Evernote Tags:</b>") + label.setMinimumWidth(100) + form.addRow(label, keep_evernote_tags) + hbox = QHBoxLayout() + hbox.insertSpacing(0, 33) + hbox.addWidget(evernote_tags_to_delete) + form.addRow("Tags to Delete:", hbox) + form.addRow(" ", delete_evernote_query_tags) + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + ############################ NOTE UPDATING ########################## + # Note Update Method + update_existing_notes = QComboBox() + update_existing_notes.setStyleSheet( + ' QComboBox { color: #3b679e; font-weight: bold; } QComboBoxItem { color: #A40F2D; font-weight: bold; } ') + update_existing_notes.addItems(["Ignore Existing Notes", "Update In-Place", + "Delete and Re-Add"]) + update_existing_notes.setCurrentIndex(mw.col.conf.get(SETTINGS.ANKI.UPDATE_EXISTING_NOTES, + UpdateExistingNotes.UpdateNotesInPlace)) + update_existing_notes.activated.connect(update_update_existing_notes) + + # Add Form Row for Note Update Method + hbox = QHBoxLayout() + hbox.insertSpacing(0, 33) + hbox.addWidget(update_existing_notes) + form.addRow("<b>Note Updating:</b>", hbox) + + # Add Note Update Method Form to Group Box + group.setLayout(form) + + # Add Note Update Method Group Box to Main Layout + layout.addWidget(group) + + # Update Visibilities of Query Options + evernote_query_text_changed() + update_evernote_query_visibilities() + + + # Vertical Spacer + vertical_spacer = QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) + layout.addItem(vertical_spacer) + + # Parent Widget + widget.setLayout(layout) + + # New Tab + self.form.tabWidget.addTab(widget, "Anknotes") def gen_qt_hr(): - vbox = QVBoxLayout() - hr = QFrame() - hr.setAutoFillBackground(True) - hr.setFrameShape(QFrame.HLine) - hr.setStyleSheet("QFrame { background-color: #0060bf; color: #0060bf; }") - hr.setFixedHeight(2) - vbox.addWidget(hr) - vbox.addSpacing(4) - return vbox + vbox = QVBoxLayout() + hr = QFrame() + hr.setAutoFillBackground(True) + hr.setFrameShape(QFrame.HLine) + hr.setStyleSheet("QFrame { background-color: #0060bf; color: #0060bf; }") + hr.setFixedHeight(2) + vbox.addWidget(hr) + vbox.addSpacing(4) + return vbox def update_anki_deck_visibilities(): - if not default_anki_deck.text(): - anki_deck_evernote_notebook_integration.setChecked(True) - anki_deck_evernote_notebook_integration.setEnabled(False) - else: - anki_deck_evernote_notebook_integration.setEnabled(True) - anki_deck_evernote_notebook_integration.setChecked( - mw.col.conf.get(SETTINGS.ANKI_DECK_EVERNOTE_NOTEBOOK_INTEGRATION, True)) + if not default_anki_deck.text(): + anki_deck_evernote_notebook_integration.setChecked(True) + anki_deck_evernote_notebook_integration.setEnabled(False) + else: + anki_deck_evernote_notebook_integration.setEnabled(True) + anki_deck_evernote_notebook_integration.setChecked( + mw.col.conf.get(SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION, True)) def update_default_anki_deck(text): - mw.col.conf[SETTINGS.DEFAULT_ANKI_DECK] = text - update_anki_deck_visibilities() + mw.col.conf[SETTINGS.ANKI.DECKS.BASE] = text + update_anki_deck_visibilities() def update_anki_deck_evernote_notebook_integration(): - if default_anki_deck.text(): - mw.col.conf[ - SETTINGS.ANKI_DECK_EVERNOTE_NOTEBOOK_INTEGRATION] = anki_deck_evernote_notebook_integration.isChecked() + if default_anki_deck.text(): + mw.col.conf[ + SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION] = anki_deck_evernote_notebook_integration.isChecked() def update_evernote_tags_to_delete(text): - mw.col.conf[SETTINGS.EVERNOTE_TAGS_TO_DELETE] = text + mw.col.conf[SETTINGS.TAGS.TO_DELETE] = text def update_evernote_query_tags(text): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_TAGS] = text - if text: evernote_query_use_tags.setChecked(True) - evernote_query_text_changed() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.TAGS] = text + if text: evernote_query_use_tags.setChecked(True) + evernote_query_text_changed() def update_evernote_query_use_tags(): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_TAGS] = evernote_query_use_tags.isChecked() - update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_TAGS] = evernote_query_use_tags.isChecked() + update_evernote_query_visibilities() def update_evernote_query_excluded_tags(text): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_EXCLUDED_TAGS] = text - if text: evernote_query_use_excluded_tags.setChecked(True) - evernote_query_text_changed() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS] = text + if text: evernote_query_use_excluded_tags.setChecked(True) + evernote_query_text_changed() def update_evernote_query_use_excluded_tags(): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_EXCLUDED_TAGS] = evernote_query_use_excluded_tags.isChecked() - update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS] = evernote_query_use_excluded_tags.isChecked() + update_evernote_query_visibilities() def update_evernote_query_notebook(text): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_NOTEBOOK] = text - if text: evernote_query_use_notebook.setChecked(True) - evernote_query_text_changed() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.NOTEBOOK] = text + if text: evernote_query_use_notebook.setChecked(True) + evernote_query_text_changed() def update_evernote_query_use_notebook(): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_NOTEBOOK] = evernote_query_use_notebook.isChecked() - update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK] = evernote_query_use_notebook.isChecked() + update_evernote_query_visibilities() def update_evernote_query_note_title(text): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_NOTE_TITLE] = text - if text: evernote_query_use_note_title.setChecked(True) - evernote_query_text_changed() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.NOTE_TITLE] = text + if text: evernote_query_use_note_title.setChecked(True) + evernote_query_text_changed() def update_evernote_query_use_note_title(): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_NOTE_TITLE] = evernote_query_use_note_title.isChecked() - update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE] = evernote_query_use_note_title.isChecked() + update_evernote_query_visibilities() def update_evernote_query_use_last_updated(): - update_evernote_query_visibilities() - mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_LAST_UPDATED] = evernote_query_use_last_updated.isChecked() + update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_LAST_UPDATED] = evernote_query_use_last_updated.isChecked() def update_evernote_query_search_terms(text): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_SEARCH_TERMS] = text - if text: evernote_query_use_search_terms.setChecked(True) - evernote_query_text_changed() - update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.SEARCH_TERMS] = text + if text: evernote_query_use_search_terms.setChecked(True) + evernote_query_text_changed() + update_evernote_query_visibilities() def update_evernote_query_use_search_terms(): - update_evernote_query_visibilities() - mw.col.conf[SETTINGS.EVERNOTE_QUERY_USE_SEARCH_TERMS] = evernote_query_use_search_terms.isChecked() + update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS] = evernote_query_use_search_terms.isChecked() def update_evernote_query_any(): - update_evernote_query_visibilities() - mw.col.conf[SETTINGS.EVERNOTE_QUERY_ANY] = evernote_query_any.isChecked() + update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.ANY] = evernote_query_any.isChecked() def update_keep_evernote_tags(): - mw.col.conf[SETTINGS.KEEP_EVERNOTE_TAGS] = keep_evernote_tags.isChecked() - evernote_query_text_changed() + mw.col.conf[SETTINGS.ANKI.TAGS.KEEP_TAGS.] = keep_evernote_tags.isChecked() + evernote_query_text_changed() def update_delete_evernote_query_tags(): - mw.col.conf[SETTINGS.DELETE_EVERNOTE_TAGS_TO_IMPORT] = delete_evernote_query_tags.isChecked() + mw.col.conf[SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS] = delete_evernote_query_tags.isChecked() def update_evernote_pagination_auto_paging(): - mw.col.conf[SETTINGS.EVERNOTE_AUTO_PAGING] = evernote_pagination_auto_paging.isChecked() + mw.col.conf[SETTINGS.EVERNOTE.AUTO_PAGING] = evernote_pagination_auto_paging.isChecked() def update_evernote_pagination_current_page_spinner(value): - if value < 1: - value = 1 - evernote_pagination_current_page_spinner.setValue(1) - mw.col.conf[SETTINGS.EVERNOTE_PAGINATION_CURRENT_PAGE] = value + if value < 1: + value = 1 + evernote_pagination_current_page_spinner.setValue(1) + mw.col.conf[SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE] = value def update_update_existing_notes(index): - mw.col.conf[SETTINGS.UPDATE_EXISTING_NOTES] = index + mw.col.conf[SETTINGS.ANKI.UPDATE_EXISTING_NOTES] = index def evernote_query_text_changed(): - tags = evernote_query_tags.text() - excluded_tags = evernote_query_excluded_tags.text() - search_terms = evernote_query_search_terms.text() - note_title = evernote_query_note_title.text() - notebook = evernote_query_notebook.text() - # tags_active = tags and evernote_query_use_tags.isChecked() - search_terms_active = search_terms and evernote_query_use_search_terms.isChecked() - note_title_active = note_title and evernote_query_use_note_title.isChecked() - notebook_active = notebook and evernote_query_use_notebook.isChecked() - excluded_tags_active = excluded_tags and evernote_query_use_excluded_tags.isChecked() - all_inactive = not ( - search_terms_active or note_title_active or notebook_active or excluded_tags_active or evernote_query_use_last_updated.isChecked()) - - if not search_terms: - evernote_query_use_search_terms.setEnabled(False) - evernote_query_use_search_terms.setChecked(False) - else: - evernote_query_use_search_terms.setEnabled(True) - evernote_query_use_search_terms.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_SEARCH_TERMS, True)) - - if not note_title: - evernote_query_use_note_title.setEnabled(False) - evernote_query_use_note_title.setChecked(False) - else: - evernote_query_use_note_title.setEnabled(True) - evernote_query_use_note_title.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTE_TITLE, True)) - - if not notebook: - evernote_query_use_notebook.setEnabled(False) - evernote_query_use_notebook.setChecked(False) - else: - evernote_query_use_notebook.setEnabled(True) - evernote_query_use_notebook.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTEBOOK, True)) - - if not excluded_tags: - evernote_query_use_excluded_tags.setEnabled(False) - evernote_query_use_excluded_tags.setChecked(False) - else: - evernote_query_use_excluded_tags.setEnabled(True) - evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_EXCLUDED_TAGS, True)) - - if not tags and not all_inactive: - evernote_query_use_tags.setEnabled(False) - evernote_query_use_tags.setChecked(False) - else: - evernote_query_use_tags.setEnabled(True) - evernote_query_use_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_TAGS, True)) - if all_inactive and not tags: - evernote_query_tags.setText(SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE) + tags = evernote_query_tags.text() + excluded_tags = evernote_query_excluded_tags.text() + search_terms = evernote_query_search_terms.text() + note_title = evernote_query_note_title.text() + notebook = evernote_query_notebook.text() + # tags_active = tags and evernote_query_use_tags.isChecked() + search_terms_active = search_terms and evernote_query_use_search_terms.isChecked() + note_title_active = note_title and evernote_query_use_note_title.isChecked() + notebook_active = notebook and evernote_query_use_notebook.isChecked() + excluded_tags_active = excluded_tags and evernote_query_use_excluded_tags.isChecked() + all_inactive = not ( + search_terms_active or note_title_active or notebook_active or excluded_tags_active or evernote_query_use_last_updated.isChecked()) + + if not search_terms: + evernote_query_use_search_terms.setEnabled(False) + evernote_query_use_search_terms.setChecked(False) + else: + evernote_query_use_search_terms.setEnabled(True) + evernote_query_use_search_terms.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS, True)) + + if not note_title: + evernote_query_use_note_title.setEnabled(False) + evernote_query_use_note_title.setChecked(False) + else: + evernote_query_use_note_title.setEnabled(True) + evernote_query_use_note_title.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE, True)) + + if not notebook: + evernote_query_use_notebook.setEnabled(False) + evernote_query_use_notebook.setChecked(False) + else: + evernote_query_use_notebook.setEnabled(True) + evernote_query_use_notebook.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK, True)) + + if not excluded_tags: + evernote_query_use_excluded_tags.setEnabled(False) + evernote_query_use_excluded_tags.setChecked(False) + else: + evernote_query_use_excluded_tags.setEnabled(True) + evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True)) + + if not tags and not all_inactive: + evernote_query_use_tags.setEnabled(False) + evernote_query_use_tags.setChecked(False) + else: + evernote_query_use_tags.setEnabled(True) + evernote_query_use_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_TAGS, True)) + if all_inactive and not tags: + evernote_query_tags.setText(SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE) def update_evernote_query_visibilities(): - # is_any = evernote_query_any.isChecked() - is_tags = evernote_query_use_tags.isChecked() - is_excluded_tags = evernote_query_use_excluded_tags.isChecked() - is_terms = evernote_query_use_search_terms.isChecked() - is_title = evernote_query_use_note_title.isChecked() - is_notebook = evernote_query_use_notebook.isChecked() - is_updated = evernote_query_use_last_updated.isChecked() - - # is_disabled_any = not evernote_query_any.isEnabled() - is_disabled_tags = not evernote_query_use_tags.isEnabled() - is_disabled_excluded_tags = not evernote_query_use_excluded_tags.isEnabled() - is_disabled_terms = not evernote_query_use_search_terms.isEnabled() - is_disabled_title = not evernote_query_use_note_title.isEnabled() - is_disabled_notebook = not evernote_query_use_notebook.isEnabled() - # is_disabled_updated = not evernote_query_use_last_updated.isEnabled() - - override = (not is_tags and not is_excluded_tags and not is_terms and not is_title and not is_notebook and not is_updated) - if override: - is_tags = True - evernote_query_use_tags.setChecked(True) - evernote_query_tags.setEnabled(is_tags or is_disabled_tags) - evernote_query_excluded_tags.setEnabled(is_excluded_tags or is_disabled_excluded_tags) - evernote_query_search_terms.setEnabled(is_terms or is_disabled_terms) - evernote_query_note_title.setEnabled(is_title or is_disabled_title) - evernote_query_notebook.setEnabled(is_notebook or is_disabled_notebook) - evernote_query_last_updated_value_set_visibilities() + # is_any = evernote_query_any.isChecked() + is_tags = evernote_query_use_tags.isChecked() + is_excluded_tags = evernote_query_use_excluded_tags.isChecked() + is_terms = evernote_query_use_search_terms.isChecked() + is_title = evernote_query_use_note_title.isChecked() + is_notebook = evernote_query_use_notebook.isChecked() + is_updated = evernote_query_use_last_updated.isChecked() + + # is_disabled_any = not evernote_query_any.isEnabled() + is_disabled_tags = not evernote_query_use_tags.isEnabled() + is_disabled_excluded_tags = not evernote_query_use_excluded_tags.isEnabled() + is_disabled_terms = not evernote_query_use_search_terms.isEnabled() + is_disabled_title = not evernote_query_use_note_title.isEnabled() + is_disabled_notebook = not evernote_query_use_notebook.isEnabled() + # is_disabled_updated = not evernote_query_use_last_updated.isEnabled() + + override = (not is_tags and not is_excluded_tags and not is_terms and not is_title and not is_notebook and not is_updated) + if override: + is_tags = True + evernote_query_use_tags.setChecked(True) + evernote_query_tags.setEnabled(is_tags or is_disabled_tags) + evernote_query_excluded_tags.setEnabled(is_excluded_tags or is_disabled_excluded_tags) + evernote_query_search_terms.setEnabled(is_terms or is_disabled_terms) + evernote_query_note_title.setEnabled(is_title or is_disabled_title) + evernote_query_notebook.setEnabled(is_notebook or is_disabled_notebook) + evernote_query_last_updated_value_set_visibilities() def update_evernote_query_last_updated_type(index): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_TYPE] = index - evernote_query_last_updated_value_set_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE] = index + evernote_query_last_updated_value_set_visibilities() def evernote_query_last_updated_value_get_current_value(): - index = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_TYPE, 0) - if index < EvernoteQueryLocationType.AbsoluteDate: - spinner_text = ['day', 'week', 'month', 'year'][index] - spinner_val = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_RELATIVE, 0) - if spinner_val > 0: spinner_text += "-" + str(spinner_val) - return spinner_text - - absolute_date_str = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_DATE, - "{:%Y %m %d}".format(datetime.now() - timedelta(days=7))).replace(' ', '') - if index == EvernoteQueryLocationType.AbsoluteDate: - return absolute_date_str - absolute_time_str = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_TIME, - "{:HH mm ss}".format(datetime.now())).replace(' ', '') - return absolute_date_str + "'T'" + absolute_time_str + index = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE, 0) + if index < EvernoteQueryLocationType.AbsoluteDate: + spinner_text = ['day', 'week', 'month', 'year'][index] + spinner_val = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_RELATIVE, 0) + if spinner_val > 0: spinner_text += "-" + str(spinner_val) + return spinner_text + + absolute_date_str = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE, + "{:%Y %m %d}".format(datetime.now() - timedelta(days=7))).replace(' ', '') + if index == EvernoteQueryLocationType.AbsoluteDate: + return absolute_date_str + absolute_time_str = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME, + "{:HH mm ss}".format(datetime.now())).replace(' ', '') + return absolute_date_str + "'T'" + absolute_time_str def evernote_query_last_updated_value_set_visibilities(): - index = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_TYPE, 0) - if not evernote_query_use_last_updated.isChecked(): - evernote_query_last_updated_type.setEnabled(False) - evernote_query_last_updated_value_absolute_date.setEnabled(False) - evernote_query_last_updated_value_absolute_time.setEnabled(False) - evernote_query_last_updated_value_relative_spinner.setEnabled(False) - return - - evernote_query_last_updated_type.setEnabled(True) - evernote_query_last_updated_value_absolute_date.setEnabled(True) - evernote_query_last_updated_value_absolute_time.setEnabled(True) - evernote_query_last_updated_value_relative_spinner.setEnabled(True) - - absolute_date = QDate().fromString(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_DATE, - "{:%Y %m %d}".format(datetime.now() - timedelta(days=7))), - 'yyyy MM dd') - if index < EvernoteQueryLocationType.AbsoluteDate: - evernote_query_last_updated_value_absolute_date.setVisible(False) - evernote_query_last_updated_value_absolute_time.setVisible(False) - evernote_query_last_updated_value_relative_spinner.setVisible(True) - spinner_prefix = ['day', 'week', 'month', 'year'][index] - evernote_query_last_updated_value_relative_spinner.setPrefix(spinner_prefix) - evernote_query_last_updated_value_relative_spinner.setValue( - int(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_RELATIVE, 0))) - evernote_query_last_updated_value_stacked_layout.setCurrentIndex(0) - else: - evernote_query_last_updated_value_relative_spinner.setVisible(False) - evernote_query_last_updated_value_absolute_date.setVisible(True) - evernote_query_last_updated_value_absolute_date.setDate(absolute_date) - evernote_query_last_updated_value_stacked_layout.setCurrentIndex(1) - if index == EvernoteQueryLocationType.AbsoluteDate: - evernote_query_last_updated_value_absolute_time.setVisible(False) - evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - else: - absolute_time = QTime().fromString(mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_TIME, - "{:HH mm ss}".format(datetime.now())), 'HH mm ss') - evernote_query_last_updated_value_absolute_time.setTime(absolute_time) - evernote_query_last_updated_value_absolute_time.setVisible(True) - evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + index = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE, 0) + if not evernote_query_use_last_updated.isChecked(): + evernote_query_last_updated_type.setEnabled(False) + evernote_query_last_updated_value_absolute_date.setEnabled(False) + evernote_query_last_updated_value_absolute_time.setEnabled(False) + evernote_query_last_updated_value_relative_spinner.setEnabled(False) + return + + evernote_query_last_updated_type.setEnabled(True) + evernote_query_last_updated_value_absolute_date.setEnabled(True) + evernote_query_last_updated_value_absolute_time.setEnabled(True) + evernote_query_last_updated_value_relative_spinner.setEnabled(True) + + absolute_date = QDate().fromString(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE, + "{:%Y %m %d}".format(datetime.now() - timedelta(days=7))), + 'yyyy MM dd') + if index < EvernoteQueryLocationType.AbsoluteDate: + evernote_query_last_updated_value_absolute_date.setVisible(False) + evernote_query_last_updated_value_absolute_time.setVisible(False) + evernote_query_last_updated_value_relative_spinner.setVisible(True) + spinner_prefix = ['day', 'week', 'month', 'year'][index] + evernote_query_last_updated_value_relative_spinner.setPrefix(spinner_prefix) + evernote_query_last_updated_value_relative_spinner.setValue( + int(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_RELATIVE, 0))) + evernote_query_last_updated_value_stacked_layout.setCurrentIndex(0) + else: + evernote_query_last_updated_value_relative_spinner.setVisible(False) + evernote_query_last_updated_value_absolute_date.setVisible(True) + evernote_query_last_updated_value_absolute_date.setDate(absolute_date) + evernote_query_last_updated_value_stacked_layout.setCurrentIndex(1) + if index == EvernoteQueryLocationType.AbsoluteDate: + evernote_query_last_updated_value_absolute_time.setVisible(False) + evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + else: + absolute_time = QTime().fromString(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME, + "{:HH mm ss}".format(datetime.now())), 'HH mm ss') + evernote_query_last_updated_value_absolute_time.setTime(absolute_time) + evernote_query_last_updated_value_absolute_time.setVisible(True) + evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) def update_evernote_query_last_updated_value_relative_spinner(value): - if value < 0: - value = 0 - evernote_query_last_updated_value_relative_spinner.setValue(0) - mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_RELATIVE] = value + if value < 0: + value = 0 + evernote_query_last_updated_value_relative_spinner.setValue(0) + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_RELATIVE] = value def update_evernote_query_last_updated_value_absolute_date(date): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_DATE] = date.toString('yyyy MM dd') + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE] = date.toString('yyyy MM dd') def update_evernote_query_last_updated_value_absolute_datetime(dt): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_DATE] = dt.toString('yyyy MM dd') - mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_TIME] = dt.toString('HH mm ss') + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE] = dt.toString('yyyy MM dd') + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME] = dt.toString('HH mm ss') def update_evernote_query_last_updated_value_absolute_time(time_value): - mw.col.conf[SETTINGS.EVERNOTE_QUERY_LAST_UPDATED_VALUE_ABSOLUTE_TIME] = time_value.toString('HH mm ss') + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME] = time_value.toString('HH mm ss') def generate_evernote_query(): - query = "" - if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTEBOOK, False): - query += 'notebook:"%s" ' % mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_NOTEBOOK, - SETTINGS.EVERNOTE_QUERY_NOTEBOOK_DEFAULT_VALUE).strip() - if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_ANY, True): - query += "any: " - if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_NOTE_TITLE, False): - query_note_title = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_NOTE_TITLE, "") - if not query_note_title[:1] + query_note_title[-1:] == '""': - query_note_title = '"%s"' % query_note_title - query += 'intitle:%s ' % query_note_title - if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_TAGS, True): - tags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE).split(",") - for tag in tags: - tag = tag.strip() - if ' ' in tag: tag = '"%s"' % tag - query += 'tag:%s ' % tag - if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_EXCLUDED_TAGS, True): - tags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_EXCLUDED_TAGS, '').split(",") - for tag in tags: - tag = tag.strip() - if ' ' in tag: tag = '"%s"' % tag - query += '-tag:%s ' % tag - if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_LAST_UPDATED, False): - query += " updated:%s " % evernote_query_last_updated_value_get_current_value() - if mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_USE_SEARCH_TERMS, False): - query += mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_SEARCH_TERMS, "") - return query + query = "" + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK, False): + query += 'notebook:"%s" ' % mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTEBOOK, + SETTINGS.EVERNOTE.QUERY.NOTEBOOK_DEFAULT_VALUE).strip() + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.ANY, True): + query += "any: " + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE, False): + query_note_title = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTE_TITLE, "") + if not query_note_title[:1] + query_note_title[-1:] == '""': + query_note_title = '"%s"' % query_note_title + query += 'intitle:%s ' % query_note_title + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_TAGS, True): + tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).split(",") + for tag in tags: + tag = tag.strip() + if ' ' in tag: tag = '"%s"' % tag + query += 'tag:%s ' % tag + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True): + tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS, '').split(",") + for tag in tags: + tag = tag.strip() + if ' ' in tag: tag = '"%s"' % tag + query += '-tag:%s ' % tag + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_LAST_UPDATED, False): + query += " updated:%s " % evernote_query_last_updated_value_get_current_value() + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS, False): + query += mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.SEARCH_TERMS, "") + return query def handle_show_generated_evernote_query(): - showInfo( - "The Evernote search query for your current options is below. You can press copy the text to your clipboard by pressing the copy keyboard shortcut (CTRL+C in Windows) while this message box has focus.\n\nQuery: %s" % generate_evernote_query(), - "Evernote Search Query") + showInfo( + "The Evernote search query for your current options is below. You can press copy the text to your clipboard by pressing the copy keyboard shortcut (CTRL+C in Windows) while this message box has focus.\n\nQuery: %s" % generate_evernote_query(), + "Evernote Search Query") diff --git a/anknotes/shared.py b/anknotes/shared.py index f1b57d5..3edaf64 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -1,17 +1,18 @@ # -*- coding: utf-8 -*- ### Python Imports try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite import os +import re ### Check if in Anki try: - from aqt import mw - inAnki = True + from aqt import mw + inAnki = True except: inAnki = False - + ### Anknotes Imports from anknotes.constants import * from anknotes.logging import * @@ -21,96 +22,101 @@ ### Anki and Evernote Imports try: - from aqt.qt import QIcon, QPixmap, QPushButton, QMessageBox - from aqt.utils import tooltip - from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ - EDAMNotFoundException + from aqt.qt import QIcon, QPixmap, QPushButton, QMessageBox + from aqt.utils import tooltip + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ + EDAMNotFoundException except: - pass - + pass + # log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') def get_friendly_interval_string(lastImport): - if not lastImport: return "" - td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) - days = td.days - hours, remainder = divmod(td.total_seconds(), 3600) - minutes, seconds = divmod(remainder, 60) - if days > 1: - lastImportStr = "%d days" % td.days - else: - hours = round(hours) - hours_str = '' if hours == 0 else ('1:%2d hr' % minutes) if hours == 1 else '%d Hours' % hours - if days == 1: - lastImportStr = "One Day%s" % ('' if hours == 0 else ', ' + hours_str) - elif hours > 0: - lastImportStr = hours_str - else: - lastImportStr = "%d:%02d min" % (minutes, seconds) - return lastImportStr - + if not lastImport: return "" + td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) + days = td.days + hours, remainder = divmod(td.total_seconds(), 3600) + minutes, seconds = divmod(remainder, 60) + if days > 1: + lastImportStr = "%d days" % td.days + else: + hours = round(hours) + hours_str = '' if hours == 0 else ('1:%2d hr' % minutes) if hours == 1 else '%d Hours' % hours + if days == 1: + lastImportStr = "One Day%s" % ('' if hours == 0 else ', ' + hours_str) + elif hours > 0: + lastImportStr = hours_str + else: + lastImportStr = "%d:%02d min" % (minutes, seconds) + return lastImportStr + +def clean_evernote_css(strr): + remove_style_attrs = '-webkit-text-size-adjust: auto|-webkit-text-stroke-width: 0px|background-color: rgb(255, 255, 255)|color: rgb(0, 0, 0)|font-family: Tahoma|font-size: medium;|font-style: normal|font-variant: normal|font-weight: normal|letter-spacing: normal|orphans: 2|text-align: -webkit-auto|text-indent: 0px|text-transform: none|white-space: normal|widows: 2|word-spacing: 0px|word-wrap: break-word|-webkit-nbsp-mode: space|-webkit-line-break: after-white-space'.replace( + '(', '\\(').replace(')', '\\)') + # 'margin: 0px; padding: 0px 0px 0px 40px; ' + return re.sub(r' ?(%s);? ?' % remove_style_attrs, '', strr).replace(' style=""', '') class UpdateExistingNotes: - IgnoreExistingNotes, UpdateNotesInPlace, DeleteAndReAddNotes = range(3) + IgnoreExistingNotes, UpdateNotesInPlace, DeleteAndReAddNotes = range(3) class EvernoteQueryLocationType: - RelativeDay, RelativeWeek, RelativeMonth, RelativeYear, AbsoluteDate, AbsoluteDateTime = range(6) + RelativeDay, RelativeWeek, RelativeMonth, RelativeYear, AbsoluteDate, AbsoluteDateTime = range(6) def get_tag_names_to_import(tagNames, evernoteTags=None, evernoteTagsToDelete=None, keepEvernoteQueryTags=True): - if keepEvernoteQueryTags is None: keepEvernoteQueryTags = mw.col.conf.get(SETTINGS.DELETE_EVERNOTE_TAGS_TO_IMPORT, True) - if not keepEvernoteQueryTags: return {} if isinstance(tagNames, dict) else [] - if evernoteTags is None: evernoteTags = mw.col.conf.get(SETTINGS.EVERNOTE_QUERY_TAGS, SETTINGS.EVERNOTE_QUERY_TAGS_DEFAULT_VALUE).split(",") - if evernoteTagsToDelete is None: evernoteTagsToDelete = mw.col.conf.get(SETTINGS.EVERNOTE_TAGS_TO_DELETE, "").split(",") - tags_to_delete = evernoteTags + evernoteTagsToDelete - if isinstance(tagNames, dict): - return {k: v for k, v in tagNames.items() if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)} - return sorted([v for v in tagNames if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)], - key=lambda s: s.lower()) + if keepEvernoteQueryTags is None: keepEvernoteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, True) + if not keepEvernoteQueryTags: return {} if isinstance(tagNames, dict) else [] + if evernoteTags is None: evernoteTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).split(",") + if evernoteTagsToDelete is None: evernoteTagsToDelete = mw.col.conf.get(SETTINGS.TAGS.TO_DELETE, "").split(",") + tags_to_delete = evernoteTags + evernoteTagsToDelete + if isinstance(tagNames, dict): + return {k: v for k, v in tagNames.items() if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)} + return sorted([v for v in tagNames if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)], + key=lambda s: s.lower()) def find_evernote_guids(content): - return [x.group('guid') for x in re.finditer(r'\b(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b', content)] + return [x.group('guid') for x in re.finditer(r'\b(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b', content)] def find_evernote_links_as_guids(content): - return [x.Guid for x in find_evernote_links(content)] + return [x.Guid for x in find_evernote_links(content)] def replace_evernote_web_links(content): - return re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', - r'evernote:///view/\2/\1/\3/\3/', content) + return re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', + r'evernote:///view/\2/\1/\3/\3/', content) def find_evernote_links(content): - """ - - :param content: - :return: - :rtype : list[EvernoteLink] - """ - # .NET regex saved to regex.txt as 'Finding Evernote Links' - content = replace_evernote_web_links(content) - regex_str = r'<a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)"(?:[^>]+)?>(?P<title>.+?)</a>' - ids = get_evernote_account_ids() - if not ids.Valid: - match = re.search(regex_str, content) - if match: - ids.update(match.group('uid'), match.group('shard')) - return [EvernoteLink(m) for m in re.finditer(regex_str, content)] + """ + + :param content: + :return: + :rtype : list[EvernoteLink] + """ + # .NET regex saved to regex.txt as 'Finding Evernote Links' + content = replace_evernote_web_links(content) + regex_str = r"""(?si)<a href=["'](?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)["''](?:[^>]+)?>(?P<title>.+?)</a>""" + ids = get_evernote_account_ids() + if not ids.Valid: + match = re.search(regex_str, content) + if match: + ids.update(match.group('uid'), match.group('shard')) + return [EvernoteLink(m) for m in re.finditer(regex_str, content)] def get_dict_from_list(lst, keys_to_ignore=list()): - dic = {} - for key, value in lst: - if not key in keys_to_ignore: dic[key] = value - return dic + dic = {} + for key, value in lst: + if not key in keys_to_ignore: dic[key] = value + return dic _regex_see_also = None def update_regex(): - global _regex_see_also - regex_str = file(os.path.join(ANKNOTES.FOLDER_ANCILLARY, 'regex-see_also.txt'), 'r').read() - regex_str = regex_str.replace('(?<', '(?P<') - _regex_see_also = re.compile(regex_str, re.UNICODE | re.VERBOSE | re.DOTALL) + global _regex_see_also + regex_str = file(os.path.join(FOLDERS.ANCILLARY, 'regex-see_also.txt'), 'r').read() + regex_str = regex_str.replace('(?<', '(?P<') + _regex_see_also = re.compile(regex_str, re.UNICODE | re.VERBOSE | re.DOTALL) def regex_see_also(): - global _regex_see_also - if not _regex_see_also: update_regex() - return _regex_see_also + global _regex_see_also + if not _regex_see_also: update_regex() + return _regex_see_also diff --git a/anknotes/stopwatch/__init__.py b/anknotes/stopwatch/__init__.py index 4030498..4bac39e 100644 --- a/anknotes/stopwatch/__init__.py +++ b/anknotes/stopwatch/__init__.py @@ -7,7 +7,9 @@ # you should have received as part of this distribution. import time -# from anknotes.logging import log +from anknotes.structs import EvernoteAPIStatus +from anknotes.logging import caller_name, log, log_banner, show_report, counts_as_str +from anknotes.counters import Counter """stopwatch is a very simple Python module for measuring time. Great for finding out how long code takes to execute. @@ -26,253 +28,557 @@ Decorator exists for printing out execution times: >>> from stopwatch import clockit >>> @clockit - def mult(a, b): - return a * b + def mult(a, b): + return a * b >>> print mult(2, 6) mult in 1.38282775879e-05 sec 6 - + """ __version__ = '0.5' __author__ = 'Avinash Puchalapalli <http://www.github.com/holycrepe/>' __info__ = 'Forked from stopwatch 0.3.1 by John Paulett <http://blog.7oars.com>' +class TimerCounts(object): + Max, Current, Updated, Added, Queued, Error = (0) * 6 + +class ActionInfo(object): + Label = "" + Status = EvernoteAPIStatus.Uninitialized + __created_str__ = "Added to Anki" + __updated_str__ = "Updated in Anki" + @property + def ActionShort(self): + if self.__action_short: return self.__action_short + return (self.ActionBase.upper() + 'ING').replace(' OFING', 'ING').replace("CREATEING", "CREATING") + ' ' + self.RowItemBase.upper() + + @property + def ActionShortSingle(self): + return self.ActionShort.replace('(s)','') + + @property + def ActionTemplate(self): + if self.__action_template: return self.__action_template + return self.ActionBase + ' of {num} ' + self.RowItemFull.replace('(s)','') + + @property + def ActionBase(self): + return self.ActionTemplate.replace('{num} ', '') + + @property + def Action(self): + strNum = '' + if self.Max: + strNum = '%3d ' % self.Max + return self.ActionTemplate.replace('(s)', '' if self.Max == 1 else 's').replace('{num} ', strNum) + + @property + def Automated(self): + return self.__automated + + @property + def RowItemFull(self): + if self.__row_item_full: return self.__row_item_full + return self.RowItemBase + + @property + def RowItemBase(self): + return self.__row_item_base + + def RowSource(self): + if self.__row_source: return self.__row_source + return self.RowItemFull + + @property + def Label(self): + return self.__label + + @property + def Max(self): + return self.__max + + @property + def Interval(self): + return self.__interval + + @property + def emptyResults(self): + return (not self.Max) + + @property + def willReportProgress(self): + if not self.Max: return False + if not self.Interval or self.Interval < 1: return False + return self.Max > self.Interval + + def FormatLine(self, text, num=None): + return text.format(num=('%'+len(str(self.Max))+'d ') % num if num else '', + row_sources=self.RowSource.replace('(s)', 's'), + rows=self.RowItemFull.replace('(s)', 's'), + row=self.RowItemFull.replace('(s)', ''), + row_=self.RowItemFull, + r=self.RowItemFull.replace('(s)', '' if num == 1 else 's'), + action=self.Action + ' ' + ) + + def ActionLine(self, title, text,num=None): + return " > %s %s: %s" % (self.Action, title, self.FormatLine(text, num)) + + def Aborted(self): + return self.ActionLine("Aborted", "No Qualifying {row_sources} Found") + + def Initiated(self): + return self.ActionLine("Initiated", "{num}{r} Found", self.Max) + + def BannerHeader(self, append_newline=False): + log_banner(self.Action.upper(), self.Label, append_newline=False) + + def setStatus(self, status): + self.Status = status + return status + + def displayInitialInfo(self,max=None,interval=None, automated=None): + if max: self.__max = max + if interval: self.__interval = interval + if automated is not None: self.__automated = automated + if self.emptyResults: + if not self.automated: + show_report(self.Aborted) + return self.setStatus(EvernoteAPIStatus.EmptyRequest) + log (self.Initiated) + if self.willReportProgress: + log_banner(self.Action.upper(), self.Label, append_newline=False) + return self.setStatus(EvernoteAPIStatus.Initialized) + + def __init__(self, action_base='Upload of Validated Evernote Notes', row_item_base=None, row_item_full=None,action_template=None, label=None, auto_label=True, max=None, automated=False, interval=None, row_source=None): + if label is None and auto_label is True: + label = caller_name() + showInfo(label) + if row_item_base is None: + actions = action_base.split() + assert len(actions) > 1 + action_base = actions[0] + if actions[1] == 'of': + action_base += ' of' + actions = actions[1:] + assert len(actions) > 1 + row_item_base = ' '.join(actions[1:]) + if row_item_full is None and len(actions)>2: + row_item_base = actions[-1] + row_item_full = ' '.join(actions[1:]) + self.__action_base = action_base + self.__row_item_base = row_item_base + self.__row_item_full = row_item_full + self.__row_source = row_source + self.__action_template = action_template + self.__action = self.__action_template.replace('{num} ', '') + self.__automated=automated + self.__label = label + self.__max = max + self.__interval = interval + + class Timer(object): - __times = [] - __stopped = None - __start = None - __count = 0 - __max = 0 - __laps = 0 - __interval = 100 - __parent_timer = None - """:type : Timer""" - - @property - def laps(self): - return len(self.__times) - - @property - def max(self): - return self.__max - - @max.setter - def max(self, value): - self.__max = int(value) - - @property - def parent(self): - return self.__parent_timer - - @parent.setter - def parent(self, value): - """:type value : Timer""" - self.__parent_timer = value - - @property - def parentTotal(self): - if not self.__parent_timer: return -1 - return self.__parent_timer.total - - @property - def percentOfParent(self): - if not self.__parent_timer: return -1 - return float(self.total) / float(self.parentTotal) * 100 - - @property - def percentOfParentStr(self): - return str(int(round(self.percentOfParent))) + '%' - - @property - def percentComplete(self): - return float(self.__count) / self.__max * 100 - - @property - def percentCompleteStr(self): - return str(int(round(self.percentComplete))) + '%' - - @property - def rate(self): - return self.rateCustom() - - @property - def rateStr(self): - return self.rateStrCustom() - - def rateCustom(self, unit=None): - if unit is None: unit = self.__interval - return self.elapsed/self.__count * unit - - def rateStrCustom(self, unit=None): - if unit is None: unit = self.__interval - return self.__timetostr__(self.rateCustom(unit)) - - @property - def count(self): - return self.__count - - @property - def projectedTime(self): - return self.__max * self.rateCustom(1) - - @property - def projectedTimeStr(self): - return self.__timetostr__(self.projectedTime) - - @property - def remainingTime(self): - return self.projectedTime - self.elapsed - - @property - def remainingTimeStr(self): - return self.__timetostr__(self.remainingTime) - - @property - def progress(self): - return '%4s (%3s): @ %3s/%d. %3s of %3s remain' % (self.__timetostr__(short=False), self.percentCompleteStr, self.rateStr, self.__interval, self.remainingTimeStr, self.projectedTimeStr) - - @property - def active(self): - return self.__start and not self.__stopped - - @property - def completed(self): - return self.__start and self.__stopped - - @property - def lap_info(self): - strs = [] - if self.active: - strs.append('Active: %s' % self.__timetostr__()) - elif self.completed: - strs.append('Latest: %s' % self.__timetostr__()) - elif self.laps>0: - strs.append('Last: %s' % self.__timetostr__(self.__times) ) - if self.laps > 0 + 0 if self.active or self.completed else 1: - strs.append('%2d Laps: %s' % (self.laps, self.__timetostr__(self.history))) - strs.append('Average: %s' % self.__timetostr__(self.average)) - if self.__parent_timer: - strs.append("Parent: %s" % self.__timetostr__(self.parentTotal)) - strs.append(" (%3s) " % self.percentOfParentStr) - return ' | '.join(strs) - - @property - def isProgressCheck(self): - return self.count % max(self.__interval, 1) is 0 - - def step(self, val=1): - self.__count += val - return self.isProgressCheck - - def __init__(self, begin=True, max=0, interval=100): - if begin: - self.reset() - self.__max = max - self.__interval = interval - - def start(self): - self.reset() - - def reset(self): - self.__count = 0 - if not self.__stopped: self.stop() - self.__stopped = None - self.__start = self.__time() - - def stop(self): - """Stops the clock permanently for the instance of the Timer. - Returns the time at which the instance was stopped. - """ - if not self.__start: return -1 - self.__stopped = self.__last_time() - self.__times.append(self.elapsed) - return self.elapsed - - @property - def history(self): - return sum(self.__times) - - @property - def total(self): - return self.history + self.elapsed - - @property - def average(self): - return float(self.history) / self.laps - - def elapsed(self): - """The number of seconds since the current time that the Timer - object was created. If stop() was called, it is the number - of seconds from the instance creation until stop() was called. - """ - if not self.__start: return -1 - return self.__last_time() - self.__start - - elapsed = property(elapsed) - - def start_time(self): - """The time at which the Timer instance was created. - """ - return self.__start - - start_time = property(start_time) - - def stop_time(self): - """The time at which stop() was called, or None if stop was - never called. - """ - return self.__stopped - - stop_time = property(stop_time) - - def __last_time(self): - """Return the current time or the time at which stop() was call, - if called at all. - """ - if self.__stopped is not None: - return self.__stopped - return self.__time() - - def __time(self): - """Wrapper for time.time() to allow unit testing. - """ - return time.time() - - def __timetostr__(self, total_seconds=None, short = True, pad=True): - if total_seconds is None: total_seconds=self.elapsed - total_seconds = int(round(total_seconds)) - if total_seconds < 60: - return ['%ds','%2ds'][pad] % total_seconds - m, s = divmod(total_seconds, 60) - if short: - # if total_seconds < 120: return '%dm' % (m, s) - return ['%dm','%2dm'][pad] % m - return '%d:%02d' % (m, s) - - def __str__(self): - """Nicely format the elapsed time - """ - return self.__timetostr__() - - + __times = [] + __stopped = None + __start = None + __status = EvernoteAPIStatus.Uninitialized + __counts = Counter() + __count = 0 + __count_queued = 0 + __count_error = 0 + __count_created = 0 + __count_updated = 0 + __max = 0 + __laps = 0 + __interval = 100 + __parent_timer = None + __info = None + """:type : Timer""" + + @property + def counts(self): + return self.__counts__ + + @counts.setter + def counts(self, value): + self.__counts__ = value + + @property + def laps(self): + return len(self.__times) + + @property + def max(self): + return self.__max + + @max.setter + def max(self, value): + self.__max = int(value) + + @property + def count_success(self): + return self.count_updated + self.count_created + + @property + def count_queued(self): + return self.__count_queued + @property + def count_created(self): + return self.__count_created + + @property + def count_updated(self): + return self.__count_updated + + @property + def subcount_created(self): + return self.__subcount_created + + @property + def subcount_updated(self): + return self.__subcount_updated + + @property + def count_error(self): + return self.__count_error + + @property + def is_success(self): + return self.count_success > 0 + + @property + def parent(self): + return self.__parent_timer + + @property + def label(self): + if self.info: return self.info.Label + return "" + + @parent.setter + def parent(self, value): + """:type value : Timer""" + self.__parent_timer = value + + @property + def parentTotal(self): + if not self.__parent_timer: return -1 + return self.__parent_timer.total + + @property + def percentOfParent(self): + if not self.__parent_timer: return -1 + return float(self.total) / float(self.parentTotal) * 100 + + @property + def percentOfParentStr(self): + return str(int(round(self.percentOfParent))) + '%' + + @property + def percentComplete(self): + return float(self.count) / self.__max * 100 + + @property + def percentCompleteStr(self): + return str(int(round(self.percentComplete))) + '%' + + @property + def rate(self): + return self.rateCustom() + + @property + def rateStr(self): + return self.rateStrCustom() + + def rateCustom(self, unit=None): + if unit is None: unit = self.__interval + return self.elapsed/self.count * unit + + def rateStrCustom(self, unit=None): + if unit is None: unit = self.__interval + return self.__timetostr__(self.rateCustom(unit)) + + @property + def count(self): + return self.__count + + @property + def projectedTime(self): + return self.__max * self.rateCustom(1) + + @property + def projectedTimeStr(self): + return self.__timetostr__(self.projectedTime) + + @property + def remainingTime(self): + return self.projectedTime - self.elapsed + + @property + def remainingTimeStr(self): + return self.__timetostr__(self.remainingTime) + + @property + def progress(self): + return '%5s (%3s): @ %3s/%d. %3s of %3s remain' % (self.__timetostr__(short=False), self.percentCompleteStr, self.rateStr, self.__interval, self.remainingTimeStr, self.projectedTimeStr) + + @property + def active(self): + return self.__start and not self.__stopped + + @property + def completed(self): + return self.__start and self.__stopped + + @property + def lap_info(self): + strs = [] + if self.active: + strs.append('Active: %s' % self.__timetostr__()) + elif self.completed: + strs.append('Latest: %s' % self.__timetostr__()) + elif self.laps>0: + strs.append('Last: %s' % self.__timetostr__(self.__times) ) + if self.laps > 0 + 0 if self.active or self.completed else 1: + strs.append('%2d Laps: %s' % (self.laps, self.__timetostr__(self.history))) + strs.append('Average: %s' % self.__timetostr__(self.average)) + if self.__parent_timer: + strs.append("Parent: %s" % self.__timetostr__(self.parentTotal)) + strs.append(" (%3s) " % self.percentOfParentStr) + return ' | '.join(strs) + + @property + def isProgressCheck(self): + return self.count % max(self.__interval, 1) is 0 + + @property + def status(self): + if self.hasActionInfo: return self.info.Status + return self.__status + + @status.setter + def status(self, value): + if self.hasActionInfo: self.info.Status = value + + def autoStep(self, returned_tuple, title=None, update=None, val=None) + self.step(title, val) + return self.extractStatus(returned_tuple, update) + + def extractStatus(self, returned_tuple, update=None): + self.report_result = self.reportStatus(returned_tuple[0], None) + if len(returned_tuple) == 2: return returned_tuple[1] + return returned_tuple[1:] + + def reportStatus(self, status, update=None): + """ + :type status : EvernoteAPIStatus + """ + self.status = status + if status.IsError: return self.reportError(save_status=False) + if status == EvernoteAPIStatus.RequestQueued: return self.reportQueued(save_status=False) + if status.IsSuccess: return self.reportSuccess(update, save_status=False) + return False + + def reportSuccess(self, update=None, save_status=True): + if save_status: self.status = EvernoteAPIStatus.Success + if update: self.__count_updated += 1 + else: self.__count_created += 1 + return self.count_success + + def reportError(self, save_status=True): + if save_status: self.status = EvernoteAPIStatus.GenericError + self.__count_error += 1 + return self.count_error + + def reportQueued(self, save_status=True): + if save_status: self.status = EvernoteAPIStatus.RequestQueued + self.__count_queued += 1 + return self.count_queued + + def ReportHeader(self): + return self.info.FormatLine("%s {r} successfully completed" % counts_as_str(self.count, self.max), self.count) + + def ReportSingle(self, text, count, subtext='', subcount=0) + if not count: return [] + strs = [self.info.FormatLine("%s {r} %s" % (counts_as_str(count), text), self.count)] + if subcount: strs.append("-%-3d of these were successfully %s " % (subcount, subtext)) + return strs + + def Report(self, subcount_created=0, subcount_updated=0): + str_tips = [] + self.__subcount_created = subcount_created + self.__subcount_updated = subcount_updated + str_tips += self.ReportSingle('were newly created', self.count_created, self.info.__created_str__, subcount_created) + str_tips += self.ReportSingle('already exist and were updated', self.count_updated, self.info.__updated_str__, subcount_updated) + str_tips += self.ReportSingle('were queued', self.count_queued) + if self.count_error: str_tips.append("%d Error(s) occurred " % self.count_error) + show_report(" > %s Complete" % self.info.Action, self.ReportHeader, str_tips) + + def step(self, title=None, val=None): + if val is None and unicode(title, 'utf-8', 'ignore').isnumeric(): + val = title + title = None + self.__count += val + if self.hasActionInfo and self.isProgressCheck and title: + log( self.info.ActionShortSingle + " %"+str(len('#'+str(self.max)))+"s: %s: %s" % ('#' + str(self.count), self.progress, title), self.label) + return self.isProgressCheck + + + @property + def info(self): + """ + :rtype : ActionInfo + """ + return self.__info + + @property + def automated(self): + if not self.info: return False + return self.info.Automated + + @property + def emptyResults(self) + return (not max) + + def hasActionInfo(self): + return self.info and self.max + + def __init__(self, max=None interval=100, info=None, infoStr=None, automated=None, begin=True, label=None): + """ + :type info : ActionInfo + """ + simple_label = False + self.__max = 0 if max is None else max + self.__interval = interval + if infoStr and not info: info = ActionInfo(infoStr) + if label and not info: + simple_label = True + info = ActionInfo(label, label=label) + elif label: info.__label = label + self.__info = info + self.__action_initialized = False + self.__action_attempted = self.hasActionInfo and not simple_label + if self.__action_attempted: + self.__action_initialized = info.displayInitialInfo(max=max,interval=interval, automated=automated) + if begin: + self.reset() + + @property + def willReportProgress(self): + return self.max > self.interval + + @property + def actionInitializationFailed(self): + return self.__action_attempted and not self.__action_initialized + + @property + def interval(self): + return max(self.__interval, 1) + + def start(self): + self.reset() + + def reset(self): + self.__count = self.__count_queued = self.__count_error = self.__count_created = self.__count_updated = 0 + if not self.__stopped: self.stop() + self.__stopped = None + self.__start = self.__time() + + def stop(self): + """Stops the clock permanently for the instance of the Timer. + Returns the time at which the instance was stopped. + """ + if not self.__start: return -1 + self.__stopped = self.__last_time() + self.__times.append(self.elapsed) + return self.elapsed + + @property + def history(self): + return sum(self.__times) + + @property + def total(self): + return self.history + self.elapsed + + @property + def average(self): + return float(self.history) / self.laps + + def elapsed(self): + """The number of seconds since the current time that the Timer + object was created. If stop() was called, it is the number + of seconds from the instance creation until stop() was called. + """ + if not self.__start: return -1 + return self.__last_time() - self.__start + + elapsed = property(elapsed) + + def start_time(self): + """The time at which the Timer instance was created. + """ + return self.__start + + start_time = property(start_time) + + def stop_time(self): + """The time at which stop() was called, or None if stop was + never called. + """ + return self.__stopped + + stop_time = property(stop_time) + + def __last_time(self): + """Return the current time or the time at which stop() was call, + if called at all. + """ + if self.__stopped is not None: + return self.__stopped + return self.__time() + + def __time(self): + """Wrapper for time.time() to allow unit testing. + """ + return time.time() + + def __timetostr__(self, total_seconds=None, short = True, pad=True): + if total_seconds is None: total_seconds=self.elapsed + total_seconds = int(round(total_seconds)) + if total_seconds < 60: + return ['%ds','%2ds'][pad] % total_seconds + m, s = divmod(total_seconds, 60) + if short: + # if total_seconds < 120: return '%dm' % (m, s) + return ['%dm','%2dm'][pad] % m + return '%d:%02d' % (m, s) + + def __str__(self): + """Nicely format the elapsed time + """ + return self.__timetostr__() + + all_clockit_timers = {} def clockit(func): - """Function decorator that times the evaluation of *func* and prints the - execution time. - """ - def new(*args, **kw): - # fn = func.__name__ - # print "Request to clock %s" % fn - # return func(*args, **kw) - global all_clockit_timers - fn = func.__name__ - if fn not in all_clockit_timers: - all_clockit_timers[fn] = Timer() - else: - all_clockit_timers[fn].reset() - retval = func(*args, **kw) - all_clockit_timers[fn].stop() - # print ('Function %s completed in %s\n > %s' % (fn, all_clockit_timers[fn].__timetostr__(short=False), all_clockit_timers[fn].lap_info)) - return retval - return new + """Function decorator that times the evaluation of *func* and prints the + execution time. + """ + def new(*args, **kw): + # fn = func.__name__ + # print "Request to clock %s" % fn + # return func(*args, **kw) + global all_clockit_timers + fn = func.__name__ + if fn not in all_clockit_timers: + all_clockit_timers[fn] = Timer() + else: + all_clockit_timers[fn].reset() + retval = func(*args, **kw) + all_clockit_timers[fn].stop() + # print ('Function %s completed in %s\n > %s' % (fn, all_clockit_timers[fn].__timetostr__(short=False), all_clockit_timers[fn].lap_info)) + return retval + return new diff --git a/anknotes/structs.py b/anknotes/structs.py index 63bb481..a2c6d1e 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -2,7 +2,6 @@ import anknotes from anknotes.db import * from anknotes.enum import Enum -from anknotes.logging import log, str_safe, pad_center from anknotes.html import strip_tags from anknotes.enums import * from anknotes.EvernoteNoteTitle import EvernoteNoteTitle @@ -10,673 +9,690 @@ # from evernote.edam.notestore.ttypes import NoteMetadata, NotesMetadataList def upperFirst(name): - return name[0].upper() + name[1:] + return name[0].upper() + name[1:] def getattrcallable(obj, attr): - val = getattr(obj, attr) - if callable(val): return val() - return val + val = getattr(obj, attr) + if callable(val): return val() + return val # from anknotes.EvernoteNotePrototype import EvernoteNotePrototype # from anknotes.EvernoteNoteTitle import EvernoteNoteTitle class EvernoteStruct(object): - success = False - Name = "" - Guid = "" - __sql_columns__ = "name" - __sql_table__ = TABLES.EVERNOTE.TAGS - __sql_where__ = "guid" - __attr_order__ = [] - __title_is_note_title = False - - @staticmethod - def __attr_from_key__(key): - return upperFirst(key) - - def keys(self): - return self._valid_attributes_() - - def items(self): - return [self.getAttribute(key) for key in self.__attr_order__] - - def sqlUpdateQuery(self): - columns = self.__attr_order__ if self.__attr_order__ else self.__sql_columns__ - return "INSERT OR REPLACE INTO `%s`(%s) VALUES (%s)" % (self.__sql_table__, '`' + '`,`'.join(columns) + '`', ', '.join(['?']*len(columns))) - - def sqlSelectQuery(self, allColumns=True): - return "SELECT %s FROM %s WHERE %s = '%s'" % ( - '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) - - def getFromDB(self, allColumns=True): - query = "SELECT %s FROM %s WHERE %s = '%s'" % ( - '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) - ankDB().setrowfactory() - result = ankDB().first(self.sqlSelectQuery(allColumns)) - if result: - self.success = True - self.setFromKeyedObject(result) - else: - self.success = False - return self.success - - @property - def Where(self): - return self.getAttribute(self.__sql_where__) - - @Where.setter - def Where(self, value): - self.setAttribute(self.__sql_where__, value) - - def getAttribute(self, key, default=None, raiseIfInvalidKey=False): - if not self.hasAttribute(key): - if raiseIfInvalidKey: raise KeyError - return default - return getattr(self, self.__attr_from_key__(key)) - - def hasAttribute(self, key): - return hasattr(self, self.__attr_from_key__(key)) - - def setAttribute(self, key, value): - if key == "fetch_" + self.__sql_where__: - self.setAttribute(self.__sql_where__, value) - self.getFromDB() - elif self._is_valid_attribute_(key): - setattr(self, self.__attr_from_key__(key), value) - else: - log("Not valid attribute: %s" % key) - raise KeyError - - def setAttributeByObject(self, key, keyed_object): - self.setAttribute(key, keyed_object[key]) - - def setFromKeyedObject(self, keyed_object, keys=None): - """ - - :param keyed_object: - :type: sqlite.Row | dict[str, object] | re.MatchObject | _sre.SRE_Match - :return: - """ - lst = self._valid_attributes_() - if keys or isinstance(keyed_object, dict): - pass - elif isinstance(keyed_object, type(re.search('', ''))): - keyed_object = keyed_object.groupdict() - elif hasattr(keyed_object, 'keys'): - keys = getattrcallable(keyed_object, 'keys') - elif hasattr(keyed_object, self.__sql_where__): - for key in self.keys(): - if hasattr(keyed_object, key): self.setAttribute(key, getattr(keyed_object, key)) - return True - else: - return False - - if keys is None: keys = keyed_object - for key in keys: - if key == "fetch_" + self.__sql_where__: - self.Where = keyed_object[key] - self.getFromDB() - elif key in lst: self.setAttributeByObject(key, keyed_object) - return True - - def setFromListByDefaultOrder(self, args): - max = len(self.__attr_order__) - for i, value in enumerate(args): - if i > max: - log("Unable to set attr #%d for %s to %s (Exceeds # of default attributes)" % (i, self.__class__.__name__, str_safe(value)), 'error') - return - self.setAttribute(self.__attr_order__[i], value) - - - def _valid_attributes_(self): - return set().union(self.__sql_columns__, [self.__sql_where__], self.__attr_order__) - - def _is_valid_attribute_(self, attribute): - return (attribute[0].lower() + attribute[1:]) in self._valid_attributes_() - - def __init__(self, *args, **kwargs): - if isinstance(self.__sql_columns__, str): self.__sql_columns__ = [self.__sql_columns__] - if isinstance(self.__attr_order__, str) or isinstance(self.__attr_order__, unicode): - self.__attr_order__ = self.__attr_order__.replace('|', ' ').split(' ') - args = list(args) - if args and self.setFromKeyedObject(args[0]): del args[0] - self.setFromListByDefaultOrder(args) - self.setFromKeyedObject(kwargs) + success = False + Name = "" + Guid = "" + __sql_columns__ = "name" + __sql_table__ = TABLES.EVERNOTE.TAGS + __sql_where__ = "guid" + __attr_order__ = [] + __title_is_note_title = False + + @staticmethod + def __attr_from_key__(key): + return upperFirst(key) + + def keys(self): + return self._valid_attributes_() + + def items(self): + return [self.getAttribute(key) for key in self.__attr_order__] + + def sqlUpdateQuery(self): + columns = self.__attr_order__ if self.__attr_order__ else self.__sql_columns__ + return "INSERT OR REPLACE INTO `%s`(%s) VALUES (%s)" % (self.__sql_table__, '`' + '`,`'.join(columns) + '`', ', '.join(['?']*len(columns))) + + def sqlSelectQuery(self, allColumns=True): + return "SELECT %s FROM %s WHERE %s = '%s'" % ( + '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) + + def getFromDB(self, allColumns=True): + query = "SELECT %s FROM %s WHERE %s = '%s'" % ( + '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) + ankDB().setrowfactory() + result = ankDB().first(self.sqlSelectQuery(allColumns)) + if result: + self.success = True + self.setFromKeyedObject(result) + else: + self.success = False + return self.success + + @property + def Where(self): + return self.getAttribute(self.__sql_where__) + + @Where.setter + def Where(self, value): + self.setAttribute(self.__sql_where__, value) + + def getAttribute(self, key, default=None, raiseIfInvalidKey=False): + if not self.hasAttribute(key): + if raiseIfInvalidKey: raise KeyError + return default + return getattr(self, self.__attr_from_key__(key)) + + def hasAttribute(self, key): + return hasattr(self, self.__attr_from_key__(key)) + + def setAttribute(self, key, value): + if key == "fetch_" + self.__sql_where__: + self.setAttribute(self.__sql_where__, value) + self.getFromDB() + elif self._is_valid_attribute_(key): + setattr(self, self.__attr_from_key__(key), value) + else: + raise KeyError("%s: %s is not a valid attribute" % (self.__class__.__name__, key)) + + def setAttributeByObject(self, key, keyed_object): + self.setAttribute(key, keyed_object[key]) + + def setFromKeyedObject(self, keyed_object, keys=None): + """ + + :param keyed_object: + :type: sqlite.Row | dict[str, object] | re.MatchObject | _sre.SRE_Match + :return: + """ + lst = self._valid_attributes_() + if keys or isinstance(keyed_object, dict): + pass + elif isinstance(keyed_object, type(re.search('', ''))): + keyed_object = keyed_object.groupdict() + elif hasattr(keyed_object, 'keys'): + keys = getattrcallable(keyed_object, 'keys') + elif hasattr(keyed_object, self.__sql_where__): + for key in self.keys(): + if hasattr(keyed_object, key): self.setAttribute(key, getattr(keyed_object, key)) + return True + else: + return False + + if keys is None: keys = keyed_object + for key in keys: + if key == "fetch_" + self.__sql_where__: + self.Where = keyed_object[key] + self.getFromDB() + elif key in lst: self.setAttributeByObject(key, keyed_object) + return True + + def setFromListByDefaultOrder(self, args): + max = len(self.__attr_order__) + for i, value in enumerate(args): + if i > max: + raise Exception("Argument #%d for %s (%s) exceeds the default number of attributes for the class." % (i, self.__class__.__name__, str(value))) + self.setAttribute(self.__attr_order__[i], value) + + def _valid_attributes_(self): + return set().union(self.__sql_columns__, [self.__sql_where__], self.__attr_order__) + + def _is_valid_attribute_(self, attribute): + return (attribute[0].lower() + attribute[1:]) in self._valid_attributes_() + + def __init__(self, *args, **kwargs): + if isinstance(self.__sql_columns__, str): self.__sql_columns__ = [self.__sql_columns__] + if isinstance(self.__attr_order__, str) or isinstance(self.__attr_order__, unicode): + self.__attr_order__ = self.__attr_order__.replace('|', ' ').split(' ') + args = list(args) + if args and self.setFromKeyedObject(args[0]): del args[0] + self.setFromListByDefaultOrder(args) + self.setFromKeyedObject(kwargs) class EvernoteNotebook(EvernoteStruct): - Stack = "" - __sql_columns__ = ["name", "stack"] - __sql_table__ = TABLES.EVERNOTE.NOTEBOOKS + Stack = "" + __sql_columns__ = ["name", "stack"] + __sql_table__ = TABLES.EVERNOTE.NOTEBOOKS class EvernoteTag(EvernoteStruct): - ParentGuid = "" - UpdateSequenceNum = -1 - __sql_columns__ = ["name", "parentGuid"] - __sql_table__ = TABLES.EVERNOTE.TAGS - __attr_order__ = 'guid|name|parentGuid|updateSequenceNum' + ParentGuid = "" + UpdateSequenceNum = -1 + __sql_columns__ = ["name", "parentGuid"] + __sql_table__ = TABLES.EVERNOTE.TAGS + __attr_order__ = 'guid|name|parentGuid|updateSequenceNum' class EvernoteLink(EvernoteStruct): - __uid__ = -1 - Shard = 'x999' - Guid = "" - __title__ = None - """:type: EvernoteNoteTitle.EvernoteNoteTitle """ - __attr_order__ = 'uid|shard|guid|title' - - @property - def HTML(self): - return self.Title.HTML - - @property - def Title(self): - """:rtype : EvernoteNoteTitle.EvernoteNoteTitle""" - return self.__title__ - - @property - def FullTitle(self): - return self.Title.FullTitle - - @Title.setter - def Title(self, value): - """ - :param value: - :type value : EvernoteNoteTitle.EvernoteNoteTitle | str | unicode - :return: - """ - self.__title__ = anknotes.EvernoteNoteTitle.EvernoteNoteTitle(value) - """:type : EvernoteNoteTitle.EvernoteNoteTitle""" - - @property - def Uid(self): - return int(self.__uid__) - - @Uid.setter - def Uid(self, value): - self.__uid__ = int(value) + __uid__ = -1 + Shard = 'x999' + Guid = "" + __title__ = None + """:type: EvernoteNoteTitle.EvernoteNoteTitle """ + __attr_order__ = 'uid|shard|guid|title' + + @property + def HTML(self): + return self.Title.HTML + + @property + def Title(self): + """:rtype : EvernoteNoteTitle.EvernoteNoteTitle""" + return self.__title__ + + @property + def FullTitle(self): + return self.Title.FullTitle + + @Title.setter + def Title(self, value): + """ + :param value: + :type value : EvernoteNoteTitle.EvernoteNoteTitle | str | unicode + :return: + """ + self.__title__ = anknotes.EvernoteNoteTitle.EvernoteNoteTitle(value) + """:type : EvernoteNoteTitle.EvernoteNoteTitle""" + + @property + def Uid(self): + return int(self.__uid__) + + @Uid.setter + def Uid(self, value): + self.__uid__ = int(value) class EvernoteTOCEntry(EvernoteStruct): - RealTitle = "" - """:type : str""" - OrderedList = "" - """ - HTML output of Root Title's Ordererd List - :type : str - """ - TagNames = "" - """:type : str""" - NotebookGuid = "" - def __init__(self, *args, **kwargs): - self.__attr_order__ = 'realTitle|orderedList|tagNames|notebookGuid' - super(self.__class__, self).__init__(*args, **kwargs) + RealTitle = "" + """:type : str""" + OrderedList = "" + """ + HTML output of Root Title's Ordererd List + :type : str + """ + TagNames = "" + """:type : str""" + NotebookGuid = "" + def __init__(self, *args, **kwargs): + self.__attr_order__ = 'realTitle|orderedList|tagNames|notebookGuid' + super(self.__class__, self).__init__(*args, **kwargs) class EvernoteValidationEntry(EvernoteStruct): - Guid = "" - """:type : str""" - Title = "" - """:type : str""" - Contents = "" - """:type : str""" - TagNames = "" - """:type : str""" - NotebookGuid = "" - - def __init__(self, *args, **kwargs): - # spr = super(self.__class__ , self) - # spr.__attr_order__ = self.__attr_order__ - # spr.__init__(*args, **kwargs) - self.__attr_order__ = 'guid|title|contents|tagNames|notebookGuid' - super(self.__class__, self).__init__(*args, **kwargs) + Guid = "" + """:type : str""" + Title = "" + """:type : str""" + Contents = "" + """:type : str""" + TagNames = "" + """:type : str""" + NotebookGuid = "" + + def __init__(self, *args, **kwargs): + # spr = super(self.__class__ , self) + # spr.__attr_order__ = self.__attr_order__ + # spr.__init__(*args, **kwargs) + self.__attr_order__ = 'guid|title|contents|tagNames|notebookGuid' + super(self.__class__, self).__init__(*args, **kwargs) class EvernoteAPIStatusOld(AutoNumber): - Uninitialized = -100 - """:type : EvernoteAPIStatus""" - EmptyRequest = -3 - """:type : EvernoteAPIStatus""" - Manual = -2 - """:type : EvernoteAPIStatus""" - RequestQueued = -1 - """:type : EvernoteAPIStatus""" - Success = 0 - """:type : EvernoteAPIStatus""" - RateLimitError = () - """:type : EvernoteAPIStatus""" - SocketError = () - """:type : EvernoteAPIStatus""" - UserError = () - """:type : EvernoteAPIStatus""" - NotFoundError = () - """:type : EvernoteAPIStatus""" - UnhandledError = () - """:type : EvernoteAPIStatus""" - Unknown = 100 - """:type : EvernoteAPIStatus""" - - def __getitem__(self, item): - """:rtype : EvernoteAPIStatus""" - - return super(self.__class__, self).__getitem__(item) - - # def __new__(cls, *args, **kwargs): - # """:rtype : EvernoteAPIStatus""" - # return type(cls).__new__(*args, **kwargs) - - @property - def IsError(self): - return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value - - @property - def IsSuccessful(self): - return EvernoteAPIStatus.Success.value >= self.value > EvernoteAPIStatus.Uninitialized.value - - @property - def IsSuccess(self): - return self == EvernoteAPIStatus.Success + Uninitialized = -100 + """:type : EvernoteAPIStatus""" + EmptyRequest = -3 + """:type : EvernoteAPIStatus""" + Manual = -2 + """:type : EvernoteAPIStatus""" + RequestQueued = -1 + """:type : EvernoteAPIStatus""" + Success = 0 + """:type : EvernoteAPIStatus""" + RateLimitError = () + """:type : EvernoteAPIStatus""" + SocketError = () + """:type : EvernoteAPIStatus""" + UserError = () + """:type : EvernoteAPIStatus""" + NotFoundError = () + """:type : EvernoteAPIStatus""" + UnhandledError = () + """:type : EvernoteAPIStatus""" + Unknown = 100 + """:type : EvernoteAPIStatus""" + + def __getitem__(self, item): + """:rtype : EvernoteAPIStatus""" + + return super(self.__class__, self).__getitem__(item) + + # def __new__(cls, *args, **kwargs): + # """:rtype : EvernoteAPIStatus""" + # return type(cls).__new__(*args, **kwargs) + + @property + def IsError(self): + return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value + + @property + def IsSuccessful(self): + return EvernoteAPIStatus.Success.value >= self.value > EvernoteAPIStatus.Uninitialized.value + + @property + def IsSuccess(self): + return self == EvernoteAPIStatus.Success class EvernoteAPIStatus(AutoNumberedEnum): - Uninitialized = -100 - """:type : EvernoteAPIStatus""" - EmptyRequest = -3 - """:type : EvernoteAPIStatus""" - Manual = -2 - """:type : EvernoteAPIStatus""" - RequestQueued = -1 - """:type : EvernoteAPIStatus""" - Success = 0 - """:type : EvernoteAPIStatus""" - RateLimitError = () - """:type : EvernoteAPIStatus""" - SocketError = () - """:type : EvernoteAPIStatus""" - UserError = () - """:type : EvernoteAPIStatus""" - NotFoundError = () - """:type : EvernoteAPIStatus""" - UnhandledError = () - """:type : EvernoteAPIStatus""" - Unknown = 100 - """:type : EvernoteAPIStatus""" - - # def __new__(cls, *args, **kwargs): - # """:rtype : EvernoteAPIStatus""" - # return type(cls).__new__(*args, **kwargs) - - @property - def IsError(self): - return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value - - @property - def IsSuccessful(self): - return EvernoteAPIStatus.Success.value >= self.value > EvernoteAPIStatus.Uninitialized.value - - @property - def IsSuccess(self): - return self == EvernoteAPIStatus.Success + Uninitialized = -100 + """:type : EvernoteAPIStatus""" + Initialized = -75 + """:type : EvernoteAPIStatus""" + UnableToFindStatus = -70 + """:type : EvernoteAPIStatus""" + InvalidStatus = -60 + """:type : EvernoteAPIStatus""" + Cancelled = -50 + """:type : EvernoteAPIStatus""" + Disabled = -25 + """:type : EvernoteAPIStatus""" + EmptyRequest = -10 + """:type : EvernoteAPIStatus""" + Manual = -5 + """:type : EvernoteAPIStatus""" + RequestQueued = -3 + """:type : EvernoteAPIStatus""" + ExceededLocalLimit = -2 + """:type : EvernoteAPIStatus""" + DelayedDueToRateLimit = -1 + """:type : EvernoteAPIStatus""" + Success = 0 + """:type : EvernoteAPIStatus""" + RateLimitError = () + """:type : EvernoteAPIStatus""" + SocketError = () + """:type : EvernoteAPIStatus""" + UserError = () + """:type : EvernoteAPIStatus""" + NotFoundError = () + """:type : EvernoteAPIStatus""" + UnhandledError = () + """:type : EvernoteAPIStatus""" + GenericError = () + """:type : EvernoteAPIStatus""" + Unknown = 100 + """:type : EvernoteAPIStatus""" + + # def __new__(cls, *args, **kwargs): + # """:rtype : EvernoteAPIStatus""" + # return type(cls).__new__(*args, **kwargs) + + @property + def IsError(self): + return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value + + @property + def IsDelayableError(self): + return self.value == EvernoteAPIStatus.RateLimitError.value or self.value == EvernoteAPIStatus.SocketError.value + + @property + def IsSuccessful(self): + return EvernoteAPIStatus.Success.value >= self.value >= EvernoteAPIStatus.Manual.value + + @property + def IsSuccess(self): + return self == EvernoteAPIStatus.Success class EvernoteImportType: - Add, UpdateInPlace, DeleteAndUpdate = range(3) + Add, UpdateInPlace, DeleteAndUpdate = range(3) class EvernoteNoteFetcherResult(object): - def __init__(self, note=None, status=None, source=-1): - """ + def __init__(self, note=None, status=None, source=-1): + """ - :type note: EvernoteNotePrototype.EvernoteNotePrototype - :type status: EvernoteAPIStatus - """ - if not status: status = EvernoteAPIStatus.Uninitialized - self.Note = note - self.Status = status - self.Source = source + :type note: EvernoteNotePrototype.EvernoteNotePrototype + :type status: EvernoteAPIStatus + """ + if not status: status = EvernoteAPIStatus.Uninitialized + self.Note = note + self.Status = status + self.Source = source class EvernoteNoteFetcherResults(object): - Status = EvernoteAPIStatus.Uninitialized - ImportType = EvernoteImportType.Add - Local = 0 - Notes = [] - Imported = 0 - Max = 0 - AlreadyUpToDate = 0 - - @property - def DownloadSuccess(self): - return self.Count == self.Max - - @property - def AnkiSuccess(self): - return self.Imported == self.Count - - @property - def TotalSuccess(self): - return self.DownloadSuccess and self.AnkiSuccess - - @property - def LocalDownloadsOccurred(self): - return self.Local > 0 - - @property - def Remote(self): - return self.Count - self.Local - - @property - def SummaryShort(self): - add_update_strs = ['New', "Added"] if self.ImportType == EvernoteImportType.Add else ['Existing', 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated'] - return "%d %s Notes Have Been %s" % (self.Imported, add_update_strs[0], add_update_strs[1]) - - @property - def SummaryLines(self): - if self.Max is 0: return [] - add_update_strs = ['New', "Added to"] if self.ImportType == EvernoteImportType.Add else ['Existing', "%s in" % ('Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated')] - add_update_strs[1] += " Anki" - - ## Evernote Status - if self.DownloadSuccess: - line = "All %3d" % self.Max - else: - line = "%3d of %3d" % (self.Count, self.Max) - lines = [line + " %s Evernote Metadata Results Were Successfully Downloaded%s." % ( - add_update_strs[0], (' And %s' % add_update_strs[1]) if self.AnkiSuccess else '')] - if self.Status.IsError: - lines.append("-An error occurred during download (%s)." % str(self.Status)) - - ## Local Calls - if self.LocalDownloadsOccurred: - lines.append( - "-%d %s note(s) were unexpectedly found in the local db and did not require an API call." % (self.Local, add_update_strs[0])) - lines.append("-%d %s note(s) required an API call" % (self.Remote, add_update_strs[0])) - if not self.ImportType == EvernoteImportType.Add and self.AlreadyUpToDate > 0: - lines.append( - "-%3d existing note(s) are already up-to-date with Evernote's servers, so they were not retrieved." % self.AlreadyUpToDate) - - ## Anki Status - if self.DownloadSuccess: - return lines - if self.AnkiSuccess: - line = "All %3d" % self.Imported - else: - line = "%3d of %3d" % (self.Imported, self.Count) - lines.append(line + " %s Downloaded Evernote Notes Have Been Successfully %s." % ( - add_update_strs[0], add_update_strs[1])) - - - - return lines - - @property - def Summary(self): - lines = self.SummaryLines - if len(lines) is 0: - return '' - return '<BR> - '.join(lines) - - @property - def Count(self): - return len(self.Notes) - - @property - def EvernoteFails(self): - return self.Max - self.Count - - @property - def AnkiFails(self): - return self.Count - self.Imported - - def __init__(self, status=None, local=None): - """ - :param status: - :type status : EvernoteAPIStatus - :param local: - :return: - """ - if not status: status = EvernoteAPIStatus.Uninitialized - if not local: local = 0 - self.Status = status - self.Local = local - self.Imported = 0 - self.Notes = [] - """ - :type : list[EvernoteNotePrototype.EvernoteNotePrototype] - """ - - def reportResult(self, result): - """ - :type result : EvernoteNoteFetcherResult - """ - self.Status = result.Status - if self.Status == EvernoteAPIStatus.Success: - self.Notes.append(result.Note) - if result.Source == 1: - self.Local += 1 + Status = EvernoteAPIStatus.Uninitialized + ImportType = EvernoteImportType.Add + Local = 0 + Notes = [] + Imported = 0 + Max = 0 + AlreadyUpToDate = 0 + + @property + def DownloadSuccess(self): + return self.Count == self.Max + + @property + def AnkiSuccess(self): + return self.Imported == self.Count + + @property + def TotalSuccess(self): + return self.DownloadSuccess and self.AnkiSuccess + + @property + def LocalDownloadsOccurred(self): + return self.Local > 0 + + @property + def Remote(self): + return self.Count - self.Local + + @property + def SummaryShort(self): + add_update_strs = ['New', "Added"] if self.ImportType == EvernoteImportType.Add else ['Existing', 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated'] + return "%d %s Notes Have Been %s" % (self.Imported, add_update_strs[0], add_update_strs[1]) + + @property + def SummaryLines(self): + if self.Max is 0: return [] + add_update_strs = ['New', "Added to"] if self.ImportType == EvernoteImportType.Add else ['Existing', "%s in" % ('Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated')] + add_update_strs[1] += " Anki" + + ## Evernote Status + if self.DownloadSuccess: + line = "All %3d" % self.Max + else: + line = "%3d of %3d" % (self.Count, self.Max) + lines = [line + " %s Evernote Metadata Results Were Successfully Downloaded%s." % ( + add_update_strs[0], (' And %s' % add_update_strs[1]) if self.AnkiSuccess else '')] + if self.Status.IsError: + lines.append("-An error occurred during download (%s)." % str(self.Status)) + + ## Local Calls + if self.LocalDownloadsOccurred: + lines.append( + "-%d %s note(s) were unexpectedly found in the local db and did not require an API call." % (self.Local, add_update_strs[0])) + lines.append("-%d %s note(s) required an API call" % (self.Remote, add_update_strs[0])) + if not self.ImportType == EvernoteImportType.Add and self.AlreadyUpToDate > 0: + lines.append( + "-%3d existing note(s) are already up-to-date with Evernote's servers, so they were not retrieved." % self.AlreadyUpToDate) + + ## Anki Status + if self.DownloadSuccess: + return lines + if self.AnkiSuccess: + line = "All %3d" % self.Imported + else: + line = "%3d of %3d" % (self.Imported, self.Count) + lines.append(line + " %s Downloaded Evernote Notes Have Been Successfully %s." % ( + add_update_strs[0], add_update_strs[1])) + + + + return lines + + @property + def Summary(self): + lines = self.SummaryLines + if len(lines) is 0: + return '' + return '<BR> - '.join(lines) + + @property + def Count(self): + return len(self.Notes) + + @property + def EvernoteFails(self): + return self.Max - self.Count + + @property + def AnkiFails(self): + return self.Count - self.Imported + + def __init__(self, status=None, local=None): + """ + :param status: + :type status : EvernoteAPIStatus + :param local: + :return: + """ + if not status: status = EvernoteAPIStatus.Uninitialized + if not local: local = 0 + self.Status = status + self.Local = local + self.Imported = 0 + self.Notes = [] + """ + :type : list[EvernoteNotePrototype.EvernoteNotePrototype] + """ + + def reportResult(self, result): + """ + :type result : EvernoteNoteFetcherResult + """ + self.Status = result.Status + if self.Status == EvernoteAPIStatus.Success: + self.Notes.append(result.Note) + if result.Source == 1: + self.Local += 1 class EvernoteImportProgress: - Anki = None - """:type : anknotes.Anki.Anki""" - - class _GUIDs: - Local = None - - class Server: - All = None - New = None - - class Existing: - All = None - UpToDate = None - OutOfDate = None - - def loadNew(self, server_evernote_guids=None): - if server_evernote_guids: - self.Server.All = server_evernote_guids - if not self.Server.All: - return - setServer = set(self.Server.All) - self.Server.New = setServer - set(self.Local) - self.Server.Existing.All = setServer - set(self.Server.New) - - class Results: - Adding = None - """:type : EvernoteNoteFetcherResults""" - Updating = None - """:type : EvernoteNoteFetcherResults""" - - GUIDs = _GUIDs() - - @property - def Adding(self): - return len(self.GUIDs.Server.New) - - @property - def Updating(self): - return len(self.GUIDs.Server.Existing.OutOfDate) - - @property - def AlreadyUpToDate(self): - return len(self.GUIDs.Server.Existing.UpToDate) - - @property - def Success(self): - return self.Status == EvernoteAPIStatus.Success - - @property - def IsError(self): - return self.Status.IsError - - @property - def Status(self): - s1 = self.Results.Adding.Status - s2 = self.Results.Updating.Status if self.Results.Updating else EvernoteAPIStatus.Uninitialized - if s1 == EvernoteAPIStatus.RateLimitError or s2 == EvernoteAPIStatus.RateLimitError: - return EvernoteAPIStatus.RateLimitError - if s1 == EvernoteAPIStatus.SocketError or s2 == EvernoteAPIStatus.SocketError: - return EvernoteAPIStatus.SocketError - if s1.IsError: - return s1 - if s2.IsError: - return s2 - if s1.IsSuccessful and s2.IsSuccessful: - return EvernoteAPIStatus.Success - if s2 == EvernoteAPIStatus.Uninitialized: - return s1 - if s1 == EvernoteAPIStatus.Success: - return s2 - return s1 - - @property - def Summary(self): - lst = [ - "New Notes (%d)" % self.Adding, - "Existing Out-Of-Date Notes (%d)" % self.Updating, - "Existing Up-To-Date Notes (%d)" % self.AlreadyUpToDate - ] - - return ' > '.join(lst) - - def loadAlreadyUpdated(self, db_guids): - self.GUIDs.Server.Existing.UpToDate = db_guids - self.GUIDs.Server.Existing.OutOfDate = set(self.GUIDs.Server.Existing.All) - set( - self.GUIDs.Server.Existing.UpToDate) - - def processUpdateInPlaceResults(self, results): - return self.processResults(results, EvernoteImportType.UpdateInPlace) - - def processDeleteAndUpdateResults(self, results): - return self.processResults(results, EvernoteImportType.DeleteAndUpdate) - - @property - def ResultsSummaryShort(self): - line = self.Results.Adding.SummaryShort - if self.Results.Adding.Status.IsError: - line += " to Anki. Skipping update due to an error (%s)" % self.Results.Adding.Status - elif not self.Results.Updating: - line += " to Anki. Updating is disabled" - else: - line += " and " + self.Results.Updating.SummaryShort - return line - - @property - def ResultsSummaryLines(self): - lines = [self.ResultsSummaryShort] + self.Results.Adding.SummaryLines - if self.Results.Updating: - lines += self.Results.Updating.SummaryLines - return lines - - @property - def APICallCount(self): - return self.Results.Adding.Remote + self.Results.Updating.Remote if self.Results.Updating else 0 - - def processResults(self, results, importType=None): - """ - :type results : EvernoteNoteFetcherResults - :type importType : EvernoteImportType - """ - if not importType: - importType = EvernoteImportType.Add - results.ImportType = importType - if importType == EvernoteImportType.Add: - results.Max = self.Adding - results.AlreadyUpToDate = 0 - self.Results.Adding = results - else: - results.Max = self.Updating - results.AlreadyUpToDate = self.AlreadyUpToDate - self.Results.Updating = results - - def setup(self, anki_note_ids=None): - if not anki_note_ids: - anki_note_ids = self.Anki.get_anknotes_note_ids() - self.GUIDs.Local = self.Anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) - - def __init__(self, anki=None, metadataProgress=None, server_evernote_guids=None, anki_note_ids=None): - """ - :param anki: Anknotes Main Anki Instance - :type anki: anknotes.Anki.Anki - :type metadataProgress: EvernoteMetadataProgress - :return: - """ - if not anki: - return - self.Anki = anki - self.setup(anki_note_ids) - if metadataProgress: - server_evernote_guids = metadataProgress.Guids - if server_evernote_guids: - self.GUIDs.loadNew(server_evernote_guids) - self.Results.Adding = EvernoteNoteFetcherResults() - self.Results.Updating = EvernoteNoteFetcherResults() + Anki = None + """:type : anknotes.Anki.Anki""" + + class _GUIDs: + Local = None + + class Server: + All = None + New = None + + class Existing: + All = None + UpToDate = None + OutOfDate = None + + def loadNew(self, server_evernote_guids=None): + if server_evernote_guids: + self.Server.All = server_evernote_guids + if not self.Server.All: + return + setServer = set(self.Server.All) + self.Server.New = setServer - set(self.Local) + self.Server.Existing.All = setServer - set(self.Server.New) + + class Results: + Adding = None + """:type : EvernoteNoteFetcherResults""" + Updating = None + """:type : EvernoteNoteFetcherResults""" + + GUIDs = _GUIDs() + + @property + def Adding(self): + return len(self.GUIDs.Server.New) + + @property + def Updating(self): + return len(self.GUIDs.Server.Existing.OutOfDate) + + @property + def AlreadyUpToDate(self): + return len(self.GUIDs.Server.Existing.UpToDate) + + @property + def Success(self): + return self.Status == EvernoteAPIStatus.Success + + @property + def IsError(self): + return self.Status.IsError + + @property + def Status(self): + s1 = self.Results.Adding.Status + s2 = self.Results.Updating.Status if self.Results.Updating else EvernoteAPIStatus.Uninitialized + if s1 == EvernoteAPIStatus.RateLimitError or s2 == EvernoteAPIStatus.RateLimitError: + return EvernoteAPIStatus.RateLimitError + if s1 == EvernoteAPIStatus.SocketError or s2 == EvernoteAPIStatus.SocketError: + return EvernoteAPIStatus.SocketError + if s1.IsError: + return s1 + if s2.IsError: + return s2 + if s1.IsSuccessful and s2.IsSuccessful: + return EvernoteAPIStatus.Success + if s2 == EvernoteAPIStatus.Uninitialized: + return s1 + if s1 == EvernoteAPIStatus.Success: + return s2 + return s1 + + @property + def Summary(self): + lst = [ + "New Notes (%d)" % self.Adding, + "Existing Out-Of-Date Notes (%d)" % self.Updating, + "Existing Up-To-Date Notes (%d)" % self.AlreadyUpToDate + ] + + return ' > '.join(lst) + + def loadAlreadyUpdated(self, db_guids): + self.GUIDs.Server.Existing.UpToDate = db_guids + self.GUIDs.Server.Existing.OutOfDate = set(self.GUIDs.Server.Existing.All) - set( + self.GUIDs.Server.Existing.UpToDate) + + def processUpdateInPlaceResults(self, results): + return self.processResults(results, EvernoteImportType.UpdateInPlace) + + def processDeleteAndUpdateResults(self, results): + return self.processResults(results, EvernoteImportType.DeleteAndUpdate) + + @property + def ResultsSummaryShort(self): + line = self.Results.Adding.SummaryShort + if self.Results.Adding.Status.IsError: + line += " to Anki. Skipping update due to an error (%s)" % self.Results.Adding.Status + elif not self.Results.Updating: + line += " to Anki. Updating is disabled" + else: + line += " and " + self.Results.Updating.SummaryShort + return line + + @property + def ResultsSummaryLines(self): + lines = [self.ResultsSummaryShort] + self.Results.Adding.SummaryLines + if self.Results.Updating: + lines += self.Results.Updating.SummaryLines + return lines + + @property + def APICallCount(self): + return self.Results.Adding.Remote + self.Results.Updating.Remote if self.Results.Updating else 0 + + def processResults(self, results, importType=None): + """ + :type results : EvernoteNoteFetcherResults + :type importType : EvernoteImportType + """ + if not importType: + importType = EvernoteImportType.Add + results.ImportType = importType + if importType == EvernoteImportType.Add: + results.Max = self.Adding + results.AlreadyUpToDate = 0 + self.Results.Adding = results + else: + results.Max = self.Updating + results.AlreadyUpToDate = self.AlreadyUpToDate + self.Results.Updating = results + + def setup(self, anki_note_ids=None): + if not anki_note_ids: + anki_note_ids = self.Anki.get_anknotes_note_ids() + self.GUIDs.Local = self.Anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) + + def __init__(self, anki=None, metadataProgress=None, server_evernote_guids=None, anki_note_ids=None): + """ + :param anki: Anknotes Main Anki Instance + :type anki: anknotes.Anki.Anki + :type metadataProgress: EvernoteMetadataProgress + :return: + """ + if not anki: + return + self.Anki = anki + self.setup(anki_note_ids) + if metadataProgress: + server_evernote_guids = metadataProgress.Guids + if server_evernote_guids: + self.GUIDs.loadNew(server_evernote_guids) + self.Results.Adding = EvernoteNoteFetcherResults() + self.Results.Updating = EvernoteNoteFetcherResults() class EvernoteMetadataProgress: - Page = 1 - Total = -1 - Current = -1 - UpdateCount = 0 - Status = EvernoteAPIStatus.Uninitialized - Guids = [] - NotesMetadata = {} - """ - :type: dict[str, anknotes.evernote.edam.notestore.ttypes.NoteMetadata] - """ - - @property - def IsFinished(self): - return self.Remaining <= 0 - - @property - def List(self): - return ["Total Notes: %d" % self.Total, - "Returned Notes: %d" % self.Current, - "Result Range: %d-%d" % (self.Offset, self.Completed), - "Remaining Notes: %d" % self.Remaining, - "Update Count: %d" % self.UpdateCount] - - @property - def ListPadded(self): - lst = [] - for val in self.List: - - lst.append(pad_center(val, 25)) - return lst - - @property - def Summary(self): - return ' | '.join(self.ListPadded) - - @property - def Offset(self): - return (self.Page - 1) * 250 - - @property - def Completed(self): - return self.Current + self.Offset - - @property - def Remaining(self): - return self.Total - self.Completed - - def __init__(self, page=1): - self.Page = int(page) - - def loadResults(self, result): - """ - :param result: Result Returned by Evernote API Call to getNoteMetadata - :type result: anknotes.evernote.edam.notestore.ttypes.NotesMetadataList - :return: - """ - self.Total = int(result.totalNotes) - self.Current = len(result.notes) - self.UpdateCount = result.updateCount - self.Status = EvernoteAPIStatus.Success - self.Guids = [] - self.NotesMetadata = {} - for note in result.notes: - # assert isinstance(note, NoteMetadata) - self.Guids.append(note.guid) - self.NotesMetadata[note.guid] = note + Page = 1 + Total = -1 + Current = -1 + UpdateCount = 0 + Status = EvernoteAPIStatus.Uninitialized + Guids = [] + NotesMetadata = {} + """ + :type: dict[str, anknotes.evernote.edam.notestore.ttypes.NoteMetadata] + """ + + @property + def IsFinished(self): + return self.Remaining <= 0 + + @property + def List(self): + return ["Total Notes: %d" % self.Total, + "Returned Notes: %d" % self.Current, + "Result Range: %d-%d" % (self.Offset, self.Completed), + "Remaining Notes: %d" % self.Remaining, + "Update Count: %d" % self.UpdateCount] + + @property + def ListPadded(self): + lst = [] + for val in self.List: + + lst.append(val.center(25)) + return lst + + @property + def Summary(self): + return ' | '.join(self.ListPadded) + + @property + def Offset(self): + return (self.Page - 1) * 250 + + @property + def Completed(self): + return self.Current + self.Offset + + @property + def Remaining(self): + return self.Total - self.Completed + + def __init__(self, page=1): + self.Page = int(page) + + def loadResults(self, result): + """ + :param result: Result Returned by Evernote API Call to getNoteMetadata + :type result: anknotes.evernote.edam.notestore.ttypes.NotesMetadataList + :return: + """ + self.Total = int(result.totalNotes) + self.Current = len(result.notes) + self.UpdateCount = result.updateCount + self.Status = EvernoteAPIStatus.Success + self.Guids = [] + self.NotesMetadata = {} + for note in result.notes: + # assert isinstance(note, NoteMetadata) + self.Guids.append(note.guid) + self.NotesMetadata[note.guid] = note diff --git a/anknotes/toc.py b/anknotes/toc.py index 2704d81..1c23a23 100644 --- a/anknotes/toc.py +++ b/anknotes/toc.py @@ -1,333 +1,332 @@ # -*- coding: utf-8 -*- try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite from anknotes.constants import * from anknotes.html import generate_evernote_link, generate_evernote_span from anknotes.logging import log_dump from anknotes.EvernoteNoteTitle import EvernoteNoteTitle, generateTOCTitle from anknotes.EvernoteNotePrototype import EvernoteNotePrototype - def TOCNamePriority(title): - for index, value in enumerate( - ['Summary', 'Definition', 'Classification', 'Types', 'Presentation', 'Organ Involvement', 'Age of Onset', - 'Si/Sx', 'Sx', 'Sign', 'MCC\'s', 'MCC', 'Inheritance', 'Incidence', 'Prognosis', 'Mechanism', 'MOA', - 'Pathophysiology', 'Indications', 'Examples', 'Cause', 'Causes', 'Causative Organisms', 'Risk Factors', - 'Complication', 'Complications', 'Side Effects', 'Drug S/E', 'Associated Conditions', 'A/w', 'Dx', - 'Physical Exam', 'Labs', 'Hemodynamic Parameters', 'Lab Findings', 'Imaging', 'Screening Test', - 'Confirmatory Test']): - if title == value: return -1, index - for index, value in enumerate(['Management', 'Work Up', 'Tx']): - if title == value: return 1, index - return 0, 0 + for index, value in enumerate( + ['Summary', 'Definition', 'Classification', 'Types', 'Presentation', 'Organ Involvement', 'Age of Onset', + 'Si/Sx', 'Sx', 'Sign', 'MCC\'s', 'MCC', 'Inheritance', 'Incidence', 'Prognosis', 'Mechanism', 'MOA', + 'Pathophysiology', 'Indications', 'Examples', 'Cause', 'Causes', 'Causative Organisms', 'Risk Factors', + 'Complication', 'Complications', 'Side Effects', 'Drug S/E', 'Associated Conditions', 'A/w', 'Dx', + 'Physical Exam', 'Labs', 'Hemodynamic Parameters', 'Lab Findings', 'Imaging', 'Screening Test', + 'Confirmatory Test']): + if title == value: return -1, index + for index, value in enumerate(['Management', 'Work Up', 'Tx']): + if title == value: return 1, index + return 0, 0 def TOCNameSort(title1, title2): - priority1 = TOCNamePriority(title1) - priority2 = TOCNamePriority(title2) - # Lower value for item 1 = item 1 placed BEFORE item 2 - if priority1[0] != priority2[0]: return priority1[0] - priority2[0] - if priority1[1] != priority2[1]: return priority1[1] - priority2[1] - return cmp(title1, title2) + priority1 = TOCNamePriority(title1) + priority2 = TOCNamePriority(title2) + # Lower value for item 1 = item 1 placed BEFORE item 2 + if priority1[0] != priority2[0]: return priority1[0] - priority2[0] + if priority1[1] != priority2[1]: return priority1[1] - priority2[1] + return cmp(title1, title2) def TOCSort(hash1, hash2): - lvl1 = hash1.Level - lvl2 = hash2.Level - names1 = hash1.TitleParts - names2 = hash2.TitleParts - for i in range(0, min(lvl1, lvl2)): - name1 = names1[i] - name2 = names2[i] - if name1 != name2: return TOCNameSort(name1, name2) - # Lower value for item 1 = item 1 placed BEFORE item 2 - return lvl1 - lvl2 + lvl1 = hash1.Level + lvl2 = hash2.Level + names1 = hash1.TitleParts + names2 = hash2.TitleParts + for i in range(0, min(lvl1, lvl2)): + name1 = names1[i] + name2 = names2[i] + if name1 != name2: return TOCNameSort(name1, name2) + # Lower value for item 1 = item 1 placed BEFORE item 2 + return lvl1 - lvl2 class TOCHierarchyClass: - Title = None - """:type : EvernoteNoteTitle""" - Note = None - """:type : EvernoteNotePrototype.EvernoteNotePrototype""" - Outline = None - """:type : TOCHierarchyClass""" - Number = 1 - Children = [] - """:type : list[TOCHierarchyClass]""" - Parent = None - """:type : TOCHierarchyClass""" - __isSorted__ = False - - @staticmethod - def TOCItemSort(tocHierarchy1, tocHierarchy2): - lvl1 = tocHierarchy1.Level - lvl2 = tocHierarchy2.Level - names1 = tocHierarchy1.TitleParts - names2 = tocHierarchy2.TitleParts - for i in range(0, min(lvl1, lvl2)): - name1 = names1[i] - name2 = names2[i] - if name1 != name2: return TOCNameSort(name1, name2) - # Lower value for item 1 = item 1 placed BEFORE item 2 - return lvl1 - lvl2 - - @property - def IsOutline(self): - if not self.Note: return False - return EVERNOTE.TAG.OUTLINE in self.Note.Tags - - def sortIfNeeded(self): - if self.__isSorted__: return - self.sortChildren() - - @property - def Level(self): - return self.Title.Level - - @property - def ChildrenCount(self): - return len(self.Children) - - @property - def TitleParts(self): - return self.Title.TitleParts - - def addNote(self, note): - tocHierarchy = TOCHierarchyClass(note=note) - self.addHierarchy(tocHierarchy) - - def getChildIndex(self, tocChildHierarchy): - if not tocChildHierarchy in self.Children: return -1 - self.sortIfNeeded() - return self.Children.index(tocChildHierarchy) - - @property - def ListPrefix(self): - index = self.Index - isSingleItem = self.IsSingleItem - if isSingleItem is 0: return "" - if isSingleItem is 1: return "*" - return str(index) + "." - - @property - def IsSingleItem(self): - index = self.Index - if index is 0: return 0 - if index is 1 and len(self.Parent.Children) is 1: - return 1 - return -1 - - @property - def Index(self): - if not self.Parent: return 0 - return self.Parent.getChildIndex(self) + 1 - - def addTitle(self, title): - self.addHierarchy(TOCHierarchyClass(title)) - - def addHierarchy(self, tocHierarchy): - tocNewTitle = tocHierarchy.Title - tocNewLevel = tocNewTitle.Level - selfLevel = self.Title.Level - tocTestBase = tocHierarchy.Title.FullTitle.replace(self.Title.FullTitle, '') - if tocTestBase[:2] == ': ': - tocTestBase = tocTestBase[2:] - - print " \nAdd Hierarchy: %-70s --> %-40s\n-------------------------------------" % ( - self.Title.FullTitle, tocTestBase) - - if selfLevel > tocHierarchy.Title.Level: - print "New Title Level is Below current level" - return False - - selfTOCTitle = self.Title.TOCTitle - tocSelfSibling = tocNewTitle.Parents(self.Title.Level) - - if tocSelfSibling.TOCTitle != selfTOCTitle: - print "New Title doesn't match current path" - return False - - if tocNewLevel is self.Title.Level: - if tocHierarchy.IsOutline: - tocHierarchy.Parent = self - self.Outline = tocHierarchy - print "SUCCESS: Outline added" - return True - print "New Title Level is current level, but New Title is not Outline" - return False - - tocNewSelfChild = tocNewTitle.Parents(self.Title.Level + 1) - tocNewSelfChildTOCName = tocNewSelfChild.TOCName - isDirectChild = (tocHierarchy.Level == self.Level + 1) - if isDirectChild: - tocNewChildNamesTitle = "N/A" - print "New Title is a direct child of the current title" - else: - tocNewChildNamesTitle = tocHierarchy.Title.Names(self.Title.Level + 1).FullTitle - print "New Title is a Grandchild or deeper of the current title " - - for tocChild in self.Children: - assert (isinstance(tocChild, TOCHierarchyClass)) - if tocChild.Title.TOCName == tocNewSelfChildTOCName: - print "%-60s Child %-20s Match Succeeded for %s." % ( - self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) - success = tocChild.addHierarchy(tocHierarchy) - if success: - return True - print "%-60s Child %-20s Match Succeeded for %s: However, unable to add to matched child" % ( - self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) - print "%-60s Child %-20s Search failed for %s" % ( - self.Title.FullTitle + ':', tocNewSelfChild.Name, tocNewChildNamesTitle) - - newChild = tocHierarchy if isDirectChild else TOCHierarchyClass(tocNewSelfChild) - newChild.parent = self - if isDirectChild: - print "%-60s Child %-20s Created Direct Child for %s." % ( - self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) - success = True - else: - print "%-60s Child %-20s Created Title-Only Child for %-40ss." % ( - self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) - success = newChild.addHierarchy(tocHierarchy) - print "%-60s Child %-20s Created Title-Only Child for %-40s: Match %s." % ( - self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle, - "succeeded" if success else "failed") - self.__isSorted__ = False - self.Children.append(newChild) - - print "%-60s Child %-20s Appended Child for %s. Operation was an overall %s." % ( - self.Title.FullTitle + ':', newChild.Title.Name + ':', tocNewChildNamesTitle, - "success" if success else "failure") - return success - - def sortChildren(self): - self.Children = sorted(self.Children, self.TOCItemSort) - self.__isSorted__ = True - - def __strsingle__(self, fullTitle=False): - selfTitleStr = self.Title.FullTitle - selfNameStr = self.Title.Name - selfLevel = self.Title.Level - selfDepth = self.Title.Depth - selfListPrefix = self.ListPrefix - strr = '' - if selfLevel == 1: - strr += ' [%d] ' % len(self.Children) - else: - if len(self.Children): - strr += ' [%d:%2d] ' % (selfDepth, len(self.Children)) - else: - strr += ' [%d] ' % selfDepth - strr += ' ' * (selfDepth * 3) - strr += ' %s ' % selfListPrefix - - strr += '%-60s %s' % (selfTitleStr if fullTitle else selfNameStr, '' if self.Note else '(No Note)') - return strr - - def __str__(self, fullTitle=True, fullChildrenTitles=False): - self.sortIfNeeded() - lst = [self.__strsingle__(fullTitle)] - for child in self.Children: - lst.append(child.__str__(fullChildrenTitles, fullChildrenTitles)) - return '\n'.join(lst) - - def GetOrderedListItem(self, title=None): - if not title: title = self.Title.Name - selfTitleStr = title - selfLevel = self.Title.Level - selfDepth = self.Title.Depth - if selfLevel == 1: - guid = 'guid-pending' - if self.Note: guid = self.Note.Guid - link = generate_evernote_link(guid, generateTOCTitle(selfTitleStr), 'TOC') - if self.Outline: - link += ' ' + generate_evernote_link(self.Outline.Note.Guid, - '(<span style="color: rgb(255, 255, 255);">O</span>)', 'Outline', - escape=False) - return link - if self.Note: - return self.Note.generateLevelLink(selfDepth) - else: - return generate_evernote_span(selfTitleStr, 'Levels', selfDepth) - - def GetOrderedList(self, title=None): - self.sortIfNeeded() - lst = [] - header = (self.GetOrderedListItem(title)) - if self.ChildrenCount > 0: - for child in self.Children: - lst.append(child.GetOrderedList()) - childHTML = '\n'.join(lst) - else: - childHTML = '' - if childHTML: - tag = 'ol' if self.ChildrenCount > 1 else 'ul' - base = '<%s>\r\n%s\r\n</%s>\r\n' - # base = base.encode('utf8') - # tag = tag.encode('utf8') - # childHTML = childHTML.encode('utf8') - childHTML = base % (tag, childHTML, tag) - - if self.Level is 1: - base = '<div> %s </div>\r\n %s \r\n' - # base = base.encode('utf8') - # childHTML = childHTML.encode('utf8') - # header = header.encode('utf8') - base = base % (header, childHTML) - return base - base = '<li> %s \r\n %s \r\n</li> \r\n' - # base = base.encode('utf8') - # header = header.encode('utf8') - # childHTML = childHTML.encode('utf8') - base = base % (header, childHTML) - return base - - def __reprsingle__(self, fullTitle=True): - selfTitleStr = self.Title.FullTitle - selfNameStr = self.Title.Name - # selfLevel = self.title.Level - # selfDepth = self.title.Depth - selfListPrefix = self.ListPrefix - strr = "<%s:%s[%d] %s%s>" % ( - self.__class__.__name__, selfListPrefix, len(self.Children), selfTitleStr if fullTitle else selfNameStr, - '' if self.Note else ' *') - return strr - - def __repr__(self, fullTitle=True, fullChildrenTitles=False): - self.sortIfNeeded() - lst = [self.__reprsingle__(fullTitle)] - for child in self.Children: - lst.append(child.__repr__(fullChildrenTitles, fullChildrenTitles)) - return '\n'.join(lst) - - def __init__(self, title=None, note=None, number=1): - """ - :type title: EvernoteNoteTitle - :type note: EvernoteNotePrototype.EvernoteNotePrototype - """ - assert note or title - self.Outline = None - if note: - if (isinstance(note, sqlite.Row)): - note = EvernoteNotePrototype(db_note=note) - - self.Note = note - self.Title = EvernoteNoteTitle(note) - else: - self.Title = EvernoteNoteTitle(title) - self.Note = None - self.Number = number - self.Children = [] - self.__isSorted__ = False - - # - # tocTest = TOCHierarchyClass("My Root Title") - # tocTest.addTitle("My Root Title: Somebody") - # tocTest.addTitle("My Root Title: Somebody: Else") - # tocTest.addTitle("My Root Title: Someone") - # tocTest.addTitle("My Root Title: Someone: Else") - # tocTest.addTitle("My Root Title: Someone: Else: Entirely") - # tocTest.addTitle("My Root Title: Z This: HasNo: Direct Parent") - # pass + Title = None + """:type : EvernoteNoteTitle""" + Note = None + """:type : EvernoteNotePrototype.EvernoteNotePrototype""" + Outline = None + """:type : TOCHierarchyClass""" + Number = 1 + Children = [] + """:type : list[TOCHierarchyClass]""" + Parent = None + """:type : TOCHierarchyClass""" + __isSorted__ = False + + @staticmethod + def TOCItemSort(tocHierarchy1, tocHierarchy2): + lvl1 = tocHierarchy1.Level + lvl2 = tocHierarchy2.Level + names1 = tocHierarchy1.TitleParts + names2 = tocHierarchy2.TitleParts + for i in range(0, min(lvl1, lvl2)): + name1 = names1[i] + name2 = names2[i] + if name1 != name2: return TOCNameSort(name1, name2) + # Lower value for item 1 = item 1 placed BEFORE item 2 + return lvl1 - lvl2 + + @property + def IsOutline(self): + if not self.Note: return False + return TAGS.OUTLINE in self.Note.Tags + + def sortIfNeeded(self): + if self.__isSorted__: return + self.sortChildren() + + @property + def Level(self): + return self.Title.Level + + @property + def ChildrenCount(self): + return len(self.Children) + + @property + def TitleParts(self): + return self.Title.TitleParts + + def addNote(self, note): + tocHierarchy = TOCHierarchyClass(note=note) + self.addHierarchy(tocHierarchy) + + def getChildIndex(self, tocChildHierarchy): + if not tocChildHierarchy in self.Children: return -1 + self.sortIfNeeded() + return self.Children.index(tocChildHierarchy) + + @property + def ListPrefix(self): + index = self.Index + isSingleItem = self.IsSingleItem + if isSingleItem is 0: return "" + if isSingleItem is 1: return "*" + return str(index) + "." + + @property + def IsSingleItem(self): + index = self.Index + if index is 0: return 0 + if index is 1 and len(self.Parent.Children) is 1: + return 1 + return -1 + + @property + def Index(self): + if not self.Parent: return 0 + return self.Parent.getChildIndex(self) + 1 + + def addTitle(self, title): + self.addHierarchy(TOCHierarchyClass(title)) + + def addHierarchy(self, tocHierarchy): + tocNewTitle = tocHierarchy.Title + tocNewLevel = tocNewTitle.Level + selfLevel = self.Title.Level + tocTestBase = tocHierarchy.Title.FullTitle.replace(self.Title.FullTitle, '') + if tocTestBase[:2] == ': ': + tocTestBase = tocTestBase[2:] + + print " \nAdd Hierarchy: %-70s --> %-40s\n-------------------------------------" % ( + self.Title.FullTitle, tocTestBase) + + if selfLevel > tocHierarchy.Title.Level: + print "New Title Level is Below current level" + return False + + selfTOCTitle = self.Title.TOCTitle + tocSelfSibling = tocNewTitle.Parents(self.Title.Level) + + if tocSelfSibling.TOCTitle != selfTOCTitle: + print "New Title doesn't match current path" + return False + + if tocNewLevel is self.Title.Level: + if tocHierarchy.IsOutline: + tocHierarchy.Parent = self + self.Outline = tocHierarchy + print "SUCCESS: Outline added" + return True + print "New Title Level is current level, but New Title is not Outline" + return False + + tocNewSelfChild = tocNewTitle.Parents(self.Title.Level + 1) + tocNewSelfChildTOCName = tocNewSelfChild.TOCName + isDirectChild = (tocHierarchy.Level == self.Level + 1) + if isDirectChild: + tocNewChildNamesTitle = "N/A" + print "New Title is a direct child of the current title" + else: + tocNewChildNamesTitle = tocHierarchy.Title.Names(self.Title.Level + 1).FullTitle + print "New Title is a Grandchild or deeper of the current title " + + for tocChild in self.Children: + assert (isinstance(tocChild, TOCHierarchyClass)) + if tocChild.Title.TOCName == tocNewSelfChildTOCName: + print "%-60s Child %-20s Match Succeeded for %s." % ( + self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + success = tocChild.addHierarchy(tocHierarchy) + if success: + return True + print "%-60s Child %-20s Match Succeeded for %s: However, unable to add to matched child" % ( + self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + print "%-60s Child %-20s Search failed for %s" % ( + self.Title.FullTitle + ':', tocNewSelfChild.Name, tocNewChildNamesTitle) + + newChild = tocHierarchy if isDirectChild else TOCHierarchyClass(tocNewSelfChild) + newChild.parent = self + if isDirectChild: + print "%-60s Child %-20s Created Direct Child for %s." % ( + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + success = True + else: + print "%-60s Child %-20s Created Title-Only Child for %-40ss." % ( + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + success = newChild.addHierarchy(tocHierarchy) + print "%-60s Child %-20s Created Title-Only Child for %-40s: Match %s." % ( + self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle, + "succeeded" if success else "failed") + self.__isSorted__ = False + self.Children.append(newChild) + + print "%-60s Child %-20s Appended Child for %s. Operation was an overall %s." % ( + self.Title.FullTitle + ':', newChild.Title.Name + ':', tocNewChildNamesTitle, + "success" if success else "failure") + return success + + def sortChildren(self): + self.Children = sorted(self.Children, self.TOCItemSort) + self.__isSorted__ = True + + def __strsingle__(self, fullTitle=False): + selfTitleStr = self.Title.FullTitle + selfNameStr = self.Title.Name + selfLevel = self.Title.Level + selfDepth = self.Title.Depth + selfListPrefix = self.ListPrefix + strr = '' + if selfLevel == 1: + strr += ' [%d] ' % len(self.Children) + else: + if len(self.Children): + strr += ' [%d:%2d] ' % (selfDepth, len(self.Children)) + else: + strr += ' [%d] ' % selfDepth + strr += ' ' * (selfDepth * 3) + strr += ' %s ' % selfListPrefix + + strr += '%-60s %s' % (selfTitleStr if fullTitle else selfNameStr, '' if self.Note else '(No Note)') + return strr + + def __str__(self, fullTitle=True, fullChildrenTitles=False): + self.sortIfNeeded() + lst = [self.__strsingle__(fullTitle)] + for child in self.Children: + lst.append(child.__str__(fullChildrenTitles, fullChildrenTitles)) + return '\n'.join(lst) + + def GetOrderedListItem(self, title=None): + if not title: title = self.Title.Name + selfTitleStr = title + selfLevel = self.Title.Level + selfDepth = self.Title.Depth + if selfLevel == 1: + guid = 'guid-pending' + if self.Note: guid = self.Note.Guid + link = generate_evernote_link(guid, generateTOCTitle(selfTitleStr), 'TOC') + if self.Outline: + link += ' ' + generate_evernote_link(self.Outline.Note.Guid, + '(<span style="color: rgb(255, 255, 255);">O</span>)', 'Outline', + escape=False) + return link + if self.Note: + return self.Note.generateLevelLink(selfDepth) + else: + return generate_evernote_span(selfTitleStr, 'Levels', selfDepth) + + def GetOrderedList(self, title=None): + self.sortIfNeeded() + lst = [] + header = (self.GetOrderedListItem(title)) + if self.ChildrenCount > 0: + for child in self.Children: + lst.append(child.GetOrderedList()) + childHTML = '\n'.join(lst) + else: + childHTML = '' + if childHTML: + tag = 'ol' if self.ChildrenCount > 1 else 'ul' + base = '<%s>\r\n%s\r\n</%s>\r\n' + # base = base.encode('utf8') + # tag = tag.encode('utf8') + # childHTML = childHTML.encode('utf8') + childHTML = base % (tag, childHTML, tag) + + if self.Level is 1: + base = '<div> %s </div>\r\n %s \r\n' + # base = base.encode('utf8') + # childHTML = childHTML.encode('utf8') + # header = header.encode('utf8') + base = base % (header, childHTML) + return base + base = '<li> %s \r\n %s \r\n</li> \r\n' + # base = base.encode('utf8') + # header = header.encode('utf8') + # childHTML = childHTML.encode('utf8') + base = base % (header, childHTML) + return base + + def __reprsingle__(self, fullTitle=True): + selfTitleStr = self.Title.FullTitle + selfNameStr = self.Title.Name + # selfLevel = self.title.Level + # selfDepth = self.title.Depth + selfListPrefix = self.ListPrefix + strr = "<%s:%s[%d] %s%s>" % ( + self.__class__.__name__, selfListPrefix, len(self.Children), selfTitleStr if fullTitle else selfNameStr, + '' if self.Note else ' *') + return strr + + def __repr__(self, fullTitle=True, fullChildrenTitles=False): + self.sortIfNeeded() + lst = [self.__reprsingle__(fullTitle)] + for child in self.Children: + lst.append(child.__repr__(fullChildrenTitles, fullChildrenTitles)) + return '\n'.join(lst) + + def __init__(self, title=None, note=None, number=1): + """ + :type title: EvernoteNoteTitle + :type note: EvernoteNotePrototype.EvernoteNotePrototype + """ + assert note or title + self.Outline = None + if note: + if (isinstance(note, sqlite.Row)): + note = EvernoteNotePrototype(db_note=note) + + self.Note = note + self.Title = EvernoteNoteTitle(note) + else: + self.Title = EvernoteNoteTitle(title) + self.Note = None + self.Number = number + self.Children = [] + self.__isSorted__ = False + + # + # tocTest = TOCHierarchyClass("My Root Title") + # tocTest.addTitle("My Root Title: Somebody") + # tocTest.addTitle("My Root Title: Somebody: Else") + # tocTest.addTitle("My Root Title: Someone") + # tocTest.addTitle("My Root Title: Someone: Else") + # tocTest.addTitle("My Root Title: Someone: Else: Entirely") + # tocTest.addTitle("My Root Title: Z This: HasNo: Direct Parent") + # pass diff --git a/anknotes_start_bare.py b/anknotes_start_detect_see_also_changes.py similarity index 54% rename from anknotes_start_bare.py rename to anknotes_start_detect_see_also_changes.py index e386237..32c8c46 100644 --- a/anknotes_start_bare.py +++ b/anknotes_start_detect_see_also_changes.py @@ -5,5 +5,5 @@ isAnki = False if not isAnki: - from anknotes import bare - bare.main_bare() \ No newline at end of file + from anknotes import detect_see_also_changes + detect_see_also_changes.main() \ No newline at end of file diff --git a/anknotes_start_note_validation.py b/anknotes_start_note_validation.py index de0fd87..2a6ebaa 100644 --- a/anknotes_start_note_validation.py +++ b/anknotes_start_note_validation.py @@ -2,119 +2,119 @@ from anknotes import stopwatch import time try: - from lxml import etree - eTreeImported=True + from lxml import etree + eTreeImported=True except: - eTreeImported=False + eTreeImported=False if eTreeImported: - try: - from pysqlite2 import dbapi2 as sqlite - except ImportError: - from sqlite3 import dbapi2 as sqlite - - ### Anknotes Module Imports for Stand Alone Scripts - from anknotes import evernote as evernote - - ### Anknotes Shared Imports - from anknotes.shared import * - from anknotes.error import * - from anknotes.toc import TOCHierarchyClass - - ### Anknotes Class Imports - from anknotes.AnkiNotePrototype import AnkiNotePrototype - from anknotes.EvernoteNoteTitle import generateTOCTitle - - ### Anknotes Main Imports - from anknotes.Anki import Anki - from anknotes.ankEvernote import Evernote - # from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher - # from anknotes.EvernoteNotes import EvernoteNotes - # from anknotes.EvernoteNotePrototype import EvernoteNotePrototype - # from anknotes.EvernoteImporter import EvernoteImporter - # - # ### Evernote Imports - # from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec - # from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote - from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException - # from anknotes.evernote.api.client import EvernoteClient - - - ankDBSetLocal() - db = ankDB() - db.Init() - - failed_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) - pending_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 0" % TABLES.MAKE_NOTE_QUEUE) - success_queued_items = db.all("SELECT * FROM %s WHERE validation_status = -1 " % TABLES.MAKE_NOTE_QUEUE) - - currentLog = 'Successful' - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d SUCCESSFUL MAKE NOTE QUEUE ITEMS " % len(success_queued_items), 'MakeNoteQueue\\' + currentLog, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - - for result in success_queued_items: - line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' - line += result['title'] - log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=False) - - - currentLog = 'Failed' - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d FAILED MAKE NOTE QUEUE ITEMS " % len(failed_queued_items), 'MakeNoteQueue\\' + currentLog, clear=False, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - - - for result in failed_queued_items: - line = '%-60s ' % (result['title'] + ':') - line += (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW" - line += result['validation_result'] - log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) - log(result['contents'], 'MakeNoteQueue\\'+currentLog, timestamp=False) - log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) - - EN = Evernote() - - currentLog = 'Pending' - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) - log(" CHECKING %3d PENDING MAKE NOTE QUEUE ITEMS " % len(pending_queued_items), 'MakeNoteQueue\\' + currentLog, clear=False, timestamp=False, do_print=True) - log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - - timerFull = stopwatch.Timer() - for result in pending_queued_items: - guid = result['guid'] - noteContents = result['contents'] - noteTitle = result['title'] - line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' - - - - - success, errors = EN.validateNoteContent(noteContents, noteTitle) - validation_status = 1 if success else -1 - - line = " SUCCESS! " if success else " FAILURE: " - line += ' ' if result['guid'] else ' NEW ' - # line += ' %-60s ' % (result['title'] + ':') - if not success: - errors = '\n * ' + '\n * '.join(errors) - log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - else: - errors = '\n'.join(errors) - - - sql = "UPDATE %s SET validation_status = %d, validation_result = '%s' WHERE " % (TABLES.MAKE_NOTE_QUEUE, validation_status, escape_text_sql(errors)) - if guid: - sql += "guid = '%s'" % guid - else: - sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) - - db.execute(sql) - - - timerFull.stop() - log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) - - db.commit() - db.close() \ No newline at end of file + try: + from pysqlite2 import dbapi2 as sqlite + except ImportError: + from sqlite3 import dbapi2 as sqlite + + ### Anknotes Module Imports for Stand Alone Scripts + from anknotes import evernote as evernote + + ### Anknotes Shared Imports + from anknotes.shared import * + from anknotes.error import * + from anknotes.toc import TOCHierarchyClass + + ### Anknotes Class Imports + from anknotes.AnkiNotePrototype import AnkiNotePrototype + from anknotes.EvernoteNoteTitle import generateTOCTitle + + ### Anknotes Main Imports + from anknotes.Anki import Anki + from anknotes.ankEvernote import Evernote + # from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher + # from anknotes.EvernoteNotes import EvernoteNotes + # from anknotes.EvernoteNotePrototype import EvernoteNotePrototype + # from anknotes.EvernoteImporter import EvernoteImporter + # + # ### Evernote Imports + # from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec + # from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException + # from anknotes.evernote.api.client import EvernoteClient + + + ankDBSetLocal() + db = ankDB() + db.Init() + + failed_queued_items = db.all("SELECT * FROM %s WHERE validation_status = -1 " % TABLES.MAKE_NOTE_QUEUE) + pending_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 0" % TABLES.MAKE_NOTE_QUEUE) + success_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) + + currentLog = 'Successful' + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d SUCCESSFUL MAKE NOTE QUEUE ITEMS " % len(success_queued_items), 'MakeNoteQueue\\' + currentLog, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + + for result in success_queued_items: + line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' + line += result['title'] + log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=False) + + + currentLog = 'Failed' + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d FAILED MAKE NOTE QUEUE ITEMS " % len(failed_queued_items), 'MakeNoteQueue\\' + currentLog, clear=False, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + + + for result in failed_queued_items: + line = '%-60s ' % (result['title'] + ':') + line += (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW" + line += '\n' + result['validation_result'] + log(line, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) + log(result['contents'], 'MakeNoteQueue\\'+currentLog, timestamp=False) + log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) + + EN = Evernote() + + currentLog = 'Pending' + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) + log(" CHECKING %3d PENDING MAKE NOTE QUEUE ITEMS " % len(pending_queued_items), 'MakeNoteQueue\\' + currentLog, clear=False, timestamp=False, do_print=True) + log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + + timerFull = stopwatch.Timer() + for result in pending_queued_items: + guid = result['guid'] + noteContents = result['contents'] + noteTitle = result['title'] + line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' + + success, errors = EN.validateNoteContent(noteContents, noteTitle) + validation_status = 1 if success else -1 + + line = " SUCCESS! " if success else " FAILURE: " + line += ' ' if result['guid'] else ' NEW ' + # line += ' %-60s ' % (result['title'] + ':') + log_dump(errors, 'LXML ERRORS', 'lxml_errors', crosspost_to_default=False) + if not success: + if not isinstance(errors, unicode) and not isinstance(errors, str): + errors = '\n * ' + '\n * '.join(errors) + log(line + errors, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + else: + if not isinstance(errors, unicode) and not isinstance(errors, str): + errors = '\n'.join(errors) + + + sql = "UPDATE %s SET validation_status = %d, validation_result = '%s' WHERE " % (TABLES.MAKE_NOTE_QUEUE, validation_status, escape_text_sql(errors)) + if guid: + sql += "guid = '%s'" % guid + else: + sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) + + db.execute(sql) + + + timerFull.stop() + log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) + + db.commit() + db.close() \ No newline at end of file From 6c077d4baeafb55a54817df9d3ff0926cd54fdb4 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Sun, 27 Sep 2015 18:37:02 -0400 Subject: [PATCH 24/70] Auto reloading modules (dev function) Automated progress counts and reporting --- .gitignore | 5 +- anknotes/Anki.py | 8 +- anknotes/AnkiNotePrototype.py | 4 +- anknotes/Controller.py | 153 +++++-------- anknotes/EvernoteNoteFetcher.py | 4 +- anknotes/__main__.py | 18 +- anknotes/addict/addict.py | 1 - anknotes/ankEvernote.py | 6 +- anknotes/constants.py | 26 +-- anknotes/counters.py | 367 +++++++++++++++++++++----------- anknotes/logging.py | 45 ++-- anknotes/menu.py | 41 ++-- anknotes/settings.py | 12 +- anknotes/shared.py | 4 +- anknotes/stopwatch/__init__.py | 248 +++++++++++---------- anknotes/structs.py | 2 + 16 files changed, 532 insertions(+), 412 deletions(-) diff --git a/.gitignore b/.gitignore index c309fa0..efaf56a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ anknotes/extra/ancillary/FrontTemplate-Processed.htm anknotes/extra/logs/ -anknotes/extra/dev/Restart Anki.lnk anknotes/extra/dev/anknotes.developer* +anknotes/extra/dev/beyond* anknotes/extra/dev/auth_tokens.txt anknotes/extra/user/ *.bk +*.lnk +*.py-bkup +*.c00k!e ################# ## Eclipse diff --git a/anknotes/Anki.py b/anknotes/Anki.py index 815f049..cc2cc12 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -84,9 +84,9 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang :return: Count of notes successfully added or updated """ count_update = 0 - tmr = stopwatch.Timer(len(evernote_notes), 100, label='AddEvernoteNotes') + tmr = stopwatch.Timer(len(evernote_notes), 100, label='AddEvernoteNotes', display_initial_info=False) if tmr.willReportProgress: - log_banner(['ADDING', 'UPDATING'][update] + " %d EVERNOTE NOTES %s ANKI" % (tmr.max, ['TO', 'IN'][update]), tmr.label, append_newline=False) + log_banner(['ADDING', 'UPDATING'][update] + " %d EVERNOTE NOTES %s ANKI" % (tmr.counts.max, ['TO', 'IN'][update]), tmr.label, append_newline=False) for ankiNote in evernote_notes: try: title = ankiNote.Title.FullTitle @@ -114,10 +114,10 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang assert ankiNote.Tags anki_note_prototype = AnkiNotePrototype(self, anki_field_info, ankiNote.TagNames, baseNote, notebookGuid=ankiNote.NotebookGuid, count=tmr.count, - count_update=tmr.count_success, max_count=tmr.max) + count_update=tmr.counts.success, max_count=tmr.counts.max) anki_note_prototype._log_update_if_unchanged_ = log_update_if_unchanged if (update and anki_note_prototype.update_note()) or (not update and anki_note_prototype.add_note() != -1): tmr.reportSuccess() - return tmr.count_success + return tmr.counts.success def delete_anki_cards(self, evernote_guids): col = self.collection() diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index d8d1e5d..24816eb 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -433,10 +433,10 @@ def update_note(self): new_guid = self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') new_title = self.Fields[FIELD.TITLE] old_title = db_title - if !isinstance(new_title, unicode): + if not isinstance(new_title, unicode): try: new_title = unicode(new_title, 'utf-8') except: do_log_title = True - if !isinstance(old_title, unicode): + if not isinstance(old_title, unicode): try: old_title = unicode(old_title, 'utf-8') except: do_log_title = True if do_log_title or new_title != old_title or new_guid != self.OriginalGuid: diff --git a/anknotes/Controller.py b/anknotes/Controller.py index dcd9ff3..36faaf0 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- ### Python Imports import socket +from datetime import datetime try: from pysqlite2 import dbapi2 as sqlite @@ -33,8 +34,8 @@ from aqt import mw DEBUG_RAISE_API_ERRORS = False - - +# load_time = datetime.now() +# log("Loaded controller at " + load_time.isoformat(), 'import') class Controller: evernoteImporter = None """:type : EvernoteImporter""" @@ -68,30 +69,22 @@ def process_unadded_see_also_notes(self): def upload_validated_notes(self, automated=False): dbRows = ankDB().all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.NOTE_VALIDATION_QUEUE) - retry=True + did_break=True notes_created, notes_updated, queries1, queries2 = ([] for i in range(4)) """ :type: (list[EvernoteNote], list[EvernoteNote], list[str], list[str]) """ noteFetcher = EvernoteNoteFetcher() - tmr = stopwatch.Timer(len(dbRows), 25, "Upload of Validated Evernote Notes") + tmr = stopwatch.Timer(len(dbRows), 25, "Upload of Validated Evernote Notes", automated=automated, enabled=EVERNOTE.UPLOAD.ENABLED, max_allowed=EVERNOTE.UPLOAD.MAX, display_initial_info=True) if tmr.actionInitializationFailed: return tmr.status, 0, 0 - if not EVERNOTE.UPLOAD.ENABLED: - tmr.info.ActionLine("Aborted", "EVERNOTE.UPLOAD.ENABLED is set to False") - return EvernoteAPIStatus.Disabled for dbRow in dbRows: entry = EvernoteValidationEntry(dbRow) evernote_guid, rootTitle, contents, tagNames, notebookGuid = entry.items() - tagNames = tagNames.split(',') - if -1 < EVERNOTE.UPLOAD.MAX <= count_update + count_create: - tmr.reportStatus(EvernoteAPIStatus.DelayedDueToRateLimit if EVERNOTE.UPLOAD.RESTART_INTERVAL > 0 else EvernoteAPIStatus.ExceededLocalLimit) - log("upload_validated_notes: Count exceeded- Breaking with status " + str(tmr.status)) - break + tagNames = tagNames.split(',') + if not tmr.checkLimits(): break whole_note = tmr.autoStep(self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid, validated=True), rootTitle, evernote_guid) if tmr.report_result == False: raise ValueError - if tmr.status.IsDelayableError: - log("upload_validated_notes: Delayable error - breaking with status " + str(tmr.status)) - break + if tmr.status.IsDelayableError: break if not tmr.status.IsSuccess: continue if not whole_note.tagNames: whole_note.tagNames = tagNames noteFetcher.addNoteFromServerToDB(whole_note, tagNames) @@ -104,104 +97,66 @@ def upload_validated_notes(self, automated=False): else: notes_created.append(note) queries2.append([rootTitle, contents]) - else: - retry=False - log("upload_validated_notes: Did not break out of for loop") - log("upload_validated_notes: Outside of the for loop ") - - tmr.Report(self.anki.add_evernote_notes(notes_created) if tmr.count_created else 0, self.anki.update_evernote_notes(notes_updated) if tmr.count_updated else 0) - if tmr.subcount_created: ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries2) - if tmr.subcount_updated: ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries1) + else: did_break=False + tmr.Report(self.anki.add_evernote_notes(notes_created) if tmr.counts.created else 0, self.anki.update_evernote_notes(notes_updated) if tmr.counts.updated else 0) + if tmr.counts.created.anki: ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries2) + if tmr.counts.updated.anki: ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries1) if tmr.is_success: ankDB().commit() - if retry and tmr.status != EvernoteAPIStatus.ExceededLocalLimit: mw.progress.timer((30 if tmr.status.IsDelayableError else EVERNOTE.UPLOAD.RESTART_INTERVAL) * 1000, lambda: self.upload_validated_notes(True), False) - return tmr.status, tmr.count, 0 + if did_break and tmr.status != EvernoteAPIStatus.ExceededLocalLimit: mw.progress.timer((30 if tmr.status.IsDelayableError else EVERNOTE.UPLOAD.RESTART_INTERVAL) * 1000, lambda: self.upload_validated_notes(True), False) + return tmr.status, tmr.counts, 0 def create_auto_toc(self): + def check_old_values(): + old_values = ankDB().first( + "SELECT guid, content FROM %s WHERE UPPER(title) = ? AND tagNames LIKE '%%,' || ? || ',%%'" % TABLES.EVERNOTE.NOTES, + rootTitle.upper(), TAGS.AUTO_TOC) + if not old_values: + log(rootTitle, 'AutoTOC-Create\\Add') + return None, contents + evernote_guid, old_content = old_values + # log(['old contents exist', old_values is None, old_values, evernote_guid, old_content]) + noteBodyUnencoded = self.evernote.makeNoteBody(contents, encode=False) + if type(old_content) != type(noteBodyUnencoded): + log([rootTitle, type(old_content), type(noteBodyUnencoded)], 'AutoTOC-Create\\Update\\Diffs\\_') + raise UnicodeWarning + old_content = old_content.replace('guid-pending', evernote_guid).replace("'", '"') + noteBodyUnencoded = noteBodyUnencoded.replace('guid-pending', evernote_guid).replace("'", '"') + if old_content == noteBodyUnencoded: + log(rootTitle, 'AutoTOC-Create\\Skipped') + tmr.reportSkipped() + return None, None + log(noteBodyUnencoded, 'AutoTOC-Create\\Update\\New\\'+rootTitle, clear=True) + log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create\\Update\\Diffs\\'+rootTitle, clear=True) + return evernote_guid, contents.replace('/guid-pending/', '/%s/' % evernote_guid).replace('/guid-pending/', '/%s/' % evernote_guid) + update_regex() NotesDB = EvernoteNotes() NotesDB.baseQuery = ANKNOTES.HIERARCHY.ROOT_TITLES_BASE_QUERY dbRows = NotesDB.populateAllNonCustomRootNotes() - # number_updated = number_created = 0 - # count = count_create = count_update = count_update_skipped = 0 - # count_queued = count_queued_create = count_queued_update = 0 - # exist = error = 0 - # status = EvernoteAPIStatus.Uninitialized notes_created, notes_updated = [], [] """ :type: (list[EvernoteNote], list[EvernoteNote]) """ - info = stopwatch.ActionInfo('Creation of Table of Content Note(s)', row_source='Root Title(s)') - tmr = stopwatch.Timer(len(dbRows), 25, info) - if tmr.actionInitializationFailed: return tmr.status, 0, 0 + info = stopwatch.ActionInfo('Creation of Table of Content Note(s)', row_source='Root Title(s)', enabled=EVERNOTE.UPLOAD.ENABLED) + tmr = stopwatch.Timer(len(dbRows), 25, info, max_allowed=EVERNOTE.UPLOAD.MAX) + tmr.label = 'create-auto_toc' + if tmr.actionInitializationFailed: return tmr.tmr.status, 0, 0 for dbRow in dbRows: + evernote_guid = None rootTitle, contents, tagNames, notebookGuid = dbRow.items() tagNames = (set(tagNames[1:-1].split(',')) | {TAGS.TOC, TAGS.AUTO_TOC} | ({"#Sandbox"} if EVERNOTE.API.IS_SANDBOXED else set())) - {TAGS.REVERSIBLE, TAGS.REVERSE_ONLY} - rootTitle = generateTOCTitle(rootTitle) - old_values = ankDB().first( - "SELECT guid, content FROM %s WHERE UPPER(title) = ? AND tagNames LIKE '%%,' || ? || ',%%'" % TABLES.EVERNOTE.NOTES, - rootTitle.upper(), TAGS.AUTO_TOC) - evernote_guid = None - noteBodyUnencoded = self.evernote.makeNoteBody(contents, encode=False) - if old_values: - evernote_guid, old_content = old_values - if type(old_content) != type(noteBodyUnencoded): - log([rootTitle, type(old_content), type(noteBodyUnencoded)], 'AutoTOC-Create-Diffs\\_') - raise UnicodeWarning - old_content = old_content.replace('guid-pending', evernote_guid) - noteBodyUnencoded = noteBodyUnencoded.replace('guid-pending', evernote_guid) - if old_content == noteBodyUnencoded: - tmr.report - count += 1 - count_update_skipped += 1 - continue - contents = contents.replace('/guid-pending/', '/%s/' % evernote_guid).replace('/guid-pending/', '/%s/' % evernote_guid) - log(noteBodyUnencoded, 'AutoTOC-Create-New\\'+rootTitle, clear=True) - log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create-Diffs\\'+rootTitle, clear=True) - if not EVERNOTE.UPLOAD.ENABLED or ( - -1 < EVERNOTE.UPLOAD.MAX <= count_update + count_create): - continue - status, whole_note = self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid) - if status.IsError: - error += 1 - if status == EvernoteAPIStatus.RateLimitError or status == EvernoteAPIStatus.SocketError: - break - else: - continue - if status == EvernoteAPIStatus.RequestQueued: - count_queued += 1 - if old_values: count_queued_update += 1 - else: count_queued_create += 1 - continue - count += 1 - if status.IsSuccess: - note = EvernoteNotePrototype(whole_note=whole_note) - if evernote_guid: - notes_updated.append(note) - count_update += 1 - else: - notes_created.append(note) - count_create += 1 - if count_update + count_create > 0: - number_updated = self.anki.update_evernote_notes(notes_updated) - number_created = self.anki.add_evernote_notes(notes_created) - count_total = count + count_queued - count_max = len(dbRows) - str_tip_header = "%s Auto TOC note(s) successfully generated" % counts_as_str(count_total, count_max) - str_tips = [] - if count_create: str_tips.append("%-3d Auto TOC note(s) were newly created " % count_create) - if number_created: str_tips.append("-%d of these were successfully added to Anki " % number_created) - if count_queued_create: str_tips.append("-%s Auto TOC note(s) are brand new and and were queued to be added to Anki " % counts_as_str(count_queued_create)) - if count_update: str_tips.append("%-3d Auto TOC note(s) already exist in local db and were updated" % count_update) - if number_updated: str_tips.append("-%s of these were successfully updated in Anki " % counts_as_str(number_updated)) - if count_queued_update: str_tips.append("-%s Auto TOC note(s) already exist in local db and were queued to be updated in Anki" % counts_as_str(count_queued_update)) - if count_update_skipped: str_tips.append("-%s Auto TOC note(s) already exist in local db and were unchanged" % counts_as_str(count_update_skipped)) - if error > 0: str_tips.append("%d Error(s) occurred " % error) - show_report(" > TOC Creation Complete: ", str_tip_header, str_tips) - - if count_queued > 0: - ankDB().commit() - - return status, count, exist + rootTitle = generateTOCTitle(rootTitle) + evernote_guid, contents = check_old_values() + if contents is None: continue + if not tmr.checkLimits(): break + whole_note = tmr.autoStep(self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid), rootTitle, evernote_guid) + if tmr.report_result == False: raise ValueError + if tmr.status.IsDelayableError: break + if not tmr.status.IsSuccess: continue + (notes_updated if evernote_guid else notes_created).append(EvernoteNotePrototype(whole_note=whole_note)) + tmr.Report(self.anki.add_evernote_notes(notes_created) if tmr.counts.created.completed else 0, self.anki.update_evernote_notes(notes_updated) if tmr.counts.updated.completed else 0) + if tmr.counts.queued: ankDB().commit() + return tmr.status, tmr.count, tmr.counts.skipped.val def update_ancillary_data(self): self.evernote.update_ancillary_data() diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index ca8283b..014850e 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -148,8 +148,8 @@ def getNoteRemoteAPICall(self): def getNoteRemote(self): if self.api_calls > EVERNOTE.IMPORT.API_CALLS_LIMIT > -1: - log("Aborting Evernote.getNoteRemote: EVERNOTE.IMPORT.API_CALLS_LIMIT of %d has been reached" % EVERNOTE.IMPORT.API_CALLS_LIMIT) - return None + log("Aborting Evernote.getNoteRemote: EVERNOTE.IMPORT.API_CALLS_LIMIT of %d has been reached" % EVERNOTE.IMPORT.API_CALLS_LIMIT) + return None if not self.getNoteRemoteAPICall(): return False # self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_evernote_guids(self.whole_note.tagGuids) self.setNoteTags(tag_guids=self.whole_note.tagGuids) diff --git a/anknotes/__main__.py b/anknotes/__main__.py index ce3d72f..7e8f6b0 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- ### Python Imports import os +import re, sre_constants try: from pysqlite2 import dbapi2 as sqlite is_pysqlite = True @@ -24,6 +25,7 @@ # from aqt.qt import QIcon, QTreeWidget, QTreeWidgetItem from aqt.qt import Qt, QIcon, QTreeWidget, QTreeWidgetItem, QDesktopServices, QUrl from aqt.webview import AnkiWebView +from anki.utils import ids2str, splitFields # from aqt.qt.Qt import MatchFlag # from aqt.qt.qt import MatchFlag @@ -130,18 +132,12 @@ def doCheck(self, field, val): ord = mods[str(mid)][1] strg = flds[ord] try: - if re.search("(?si)^"+regex+"$", strg): - nids.append(id) - except sre_constants.error: - return - if not nids: - return "0" + if re.search("(?si)^"+regex+"$", strg): nids.append(id) + except sre_constants.error: return + if not nids: return "0" return "n.id in %s" % ids2str(nids) - # val = doCheck(field, val) - - from anki.utils import ids2str, splitFields - import re, sre_constants + # val = doCheck(field, val) vtest = doCheck(self, field, val) log("FindField for %s: %s: Total %d matches " %(field, str(val), len(vtest.split(','))), 'sql-finder') return vtest @@ -203,7 +199,7 @@ def anknotes_profile_loaded(): menu.upload_validated_notes(True) import_timer_toggle() - if ANKNOTES.DEVELOPER_MODE_AUTOMATE: + if ANKNOTES.DEVELOPER_MODE.AUTOMATED: ''' For testing purposes only! Add a function here and it will automatically run on profile load diff --git a/anknotes/addict/addict.py b/anknotes/addict/addict.py index 89361fc..f678310 100644 --- a/anknotes/addict/addict.py +++ b/anknotes/addict/addict.py @@ -2,7 +2,6 @@ import re import copy - class Dict(dict): """ diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 75327f0..7bbf521 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -334,10 +334,10 @@ def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): fetcher.results.Status = EvernoteAPIStatus.EmptyRequest return fetcher.results if inAnki: - fetcher.evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).split() - fetcher.keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS., SETTINGS.ANKI.TAGS.KEEP_TAGS._DEFAULT_VALUE) + fetcher.evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() + fetcher.keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE) fetcher.deleteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, True) - fetcher.tagsToDelete = mw.col.conf.get(SETTINGS.TAGS.TO_DELETE, "").split() + fetcher.tagsToDelete = mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "").replace(',', ' ').split() for evernote_guid in self.evernote_guids: self.evernote_guid = evernote_guid if not fetcher.getNote(evernote_guid): diff --git a/anknotes/constants.py b/anknotes/constants.py index ffee426..e5159c3 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -12,7 +12,7 @@ class FOLDERS: USER = os.path.join(EXTRA, 'user') class FILES: - class LOG: + class LOGS: class FDN: ANKI_ORPHANS = 'Find Deleted Notes\\' UNIMPORTED_EVERNOTE_NOTES = ANKI_ORPHANS + 'UnimportedEvernoteNotes' @@ -31,7 +31,7 @@ class ANCILLARY: CSS_QMESSAGEBOX = os.path.join(FOLDERS.ANCILLARY, 'QMessageBox.css') ENML_DTD = os.path.join(FOLDERS.ANCILLARY, 'enml2.dtd') class SCRIPTS: - VALIDATION = os.path.join(FOLDERS.ADDONS, 'anknotes_start_note_validation.py') + VALIDATION = os.path.join(FOLDERS.ADDONS, 'anknotes_start_note_validation.py') FIND_DELETED_NOTES = os.path.join(FOLDERS.ADDONS, 'anknotes_start_find_deleted_notes.py') class GRAPHICS: class ICON: @@ -48,8 +48,10 @@ class USER: class ANKNOTES: DATE_FORMAT = '%Y-%m-%d %H:%M:%S' - DEVELOPER_MODE = (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer'))) - DEVELOPER_MODE_AUTOMATE = (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer.automate'))) + class DEVELOPER_MODE: + ENABLED = (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer'))) + AUTOMATED = ENABLED and (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer.automate'))) + AUTO_RELOAD_MODULES = True class HIERARCHY: ROOT_TITLES_BASE_QUERY = "notebookGuid != 'fdccbccf-ee70-4069-a587-82772a96d9d3'" @@ -119,11 +121,11 @@ class RESTART: API_CALLS_LIMIT = 300 class UPLOAD: ENABLED = True # Set False if debugging note creation - MAX = 25 # Set to -1 for unlimited + MAX = -1 # Set to -1 for unlimited RESTART_INTERVAL = 30 # In seconds class VALIDATION: ENABLED = True - AUTOMATE = True + AUTOMATED = True class API: CONSUMER_KEY = "holycrepe" IS_SANDBOXED = False @@ -173,11 +175,11 @@ class ANKI: class DECKS: EVERNOTE_NOTEBOOK_INTEGRATION = 'anknotesUseNotebookNameForAnkiDeckName' BASE = 'anknotesDefaultAnkiDeck' - BASE_DEFAULT_VALUE = DECKS.DEFAULT + BASE_DEFAULT_VALUE = DECKS.DEFAULT + class TAGS: + TO_DELETE = 'anknotesTagsToDelete' + KEEP_TAGS_DEFAULT_VALUE = True + KEEP_TAGS = 'anknotesTagsKeepEvernoteTags' + DELETE_EVERNOTE_QUERY_TAGS = 'anknotesTagsDeleteEvernoteQueryTags' UPDATE_EXISTING_NOTES = 'anknotesUpdateExistingNotes' - class TAGS: - TO_DELETE = 'anknotesTagsToDelete' - KEEP_TAGS_DEFAULT_VALUE = True - KEEP_TAGS = 'anknotesTagsKeepEvernoteTags' - DELETE_EVERNOTE_QUERY_TAGS = 'anknotesTagsDeleteEvernoteQueryTags' ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX = "ankNotesCheckableMenuItems" diff --git a/anknotes/counters.py b/anknotes/counters.py index 49ee936..934b239 100644 --- a/anknotes/counters.py +++ b/anknotes/counters.py @@ -1,83 +1,134 @@ from addict import Dict -import os +import os from pprint import pprint absolutely_unused_variable = os.system("cls") def print_banner(title): - print "-" * 40 + print "-" * 40 print title - print "-" * 40 + print "-" * 40 class Counter(Dict): - @staticmethod - def print_banner(title): - print "-" * 40 - print title - print "-" * 40 + def print_banner(self, title): + print self.make_banner(title) + @staticmethod + def make_banner(title): + return '\n'.join(["-" * 40, title ,"-" * 40]) + def __init__(self, *args, **kwargs): self.setCount(0) - return super(Counter, self).__init__(*args, **kwargs) - + # if not isinstance(label, unicode) and not isinstance(label, str): raise TypeError("Cannot create counter label from non-string type: " + str(label)) + self.__label__ = "root" + self.__parent_label__ = "" + self.__is_exclusive_sum__ = True + # return super(Counter, self).__init__(*args, **kwargs) + def __key_transform__(self, key): for k in self.keys(): - if k.lower() == key.lower(): return k + if k.lower() == key.lower(): return k return key # return key[0].upper() + key[1:].lower() - + __count__ = 0 - __is_exclusive_sum__ = False - # @property - # def Count(self): - # return self.__count__ - __my_attrs__ = '__count__|__is_exclusive_sum__' + __label__ = '' + __parent_label__ = '' + __is_exclusive_sum__ = False + __my_aggregates__ = 'max|max_allowed' + __my_attrs__ = '__count__|__is_exclusive_sum__|__label__|__parent_label__|__my_aggregates__' def getCount(self): - if self.__is_exclusive_sum__: return self.main_count + if self.__is_exclusive_sum__: return self.sum return self.__count__ - + def setCount(self, value): self.__is_exclusive_sum__ = False - self.__count__ = value - + self.__count__ = value + + @property + def label(self): return self.__label__ + + @property + def parent_label(self): return self.__parent_label__ + + @property + def full_label(self): return self.parent_label + ('.' if self.parent_label else '') + self.label + @property - def main_count(self): - self.print_banner("Getting main Count ") + def get(self): + return self.getCount() + + val = value = cnt = count = get + + @property + def sum(self): + # self.print_banner("Getting main Count ") sum = 0 for key in self.iterkeys(): + if key in self.__my_aggregates__.split("|"): continue val = self[key] if isinstance(val, int): - sum += val - elif isinstance(val, Counter): - if hasattr(val, 'Count'): sum += val.getCount() - - print 'main_count: ' + key + ': - ' + str(val) + ' ~ ' + str(sum) + sum += val + elif isinstance(val, Counter) or isinstance(val, EvernoteCounter): + sum += val.getCount() + # print 'sum: ' + key + ': - ' + str(val) + ' ~ ' + str(sum) return sum + + def increment(self, y=1, negate=False): + newCount = self.__sub__(y) if negate else self.__add__(y) + # print "Incrementing %s by %d to %d" % (self.full_label, y, newCount) + self.setCount(newCount) + return newCount + + step = increment + + def __coerce__(self, y): return (self.getCount(), y) + + def __div__(self, y): + return self.getCount() / y - @property - def value(self): - if not hasattr(self, 'Count'): return 0 - return self.getCount() + def __rdiv__(self, y): + return y / self.getCount() + + __truediv__ = __div__ + def __mul__(self, y): return y * self.getCount() + __rmul__ = __mul__ - def increment(self, y): - # from copy import deepcopy - self.setCount(self.getCount() + y) - # return copy + def __sub__(self, y): + return self.getCount() - y + # return self.__add__(y, negate=True) + def __add__(self, y, negate=False): + # if isinstance(y, Counter): + # print "y=getCount: %s" % str(y) + # y = y.getCount() + return self.getCount() + y + # * (-1 if negate else 1) - def __sub__(self, y): - return self.__add__(-1 * y) - print " Want to subtr y " + y + __radd__ = __add__ + + def __rsub__(self, y, negate=False): + return y - self.getCount() - def __sum__(self): - return 12 - # def __add__(self, y): - # return self.Count + y + def __iadd__(self, y): + self.increment(y) + + def __isub__(self, y): + self.increment(y, negate=True) - def __setattr__(self, key, value): + def __truth__(self): + print "truth" + return True + + def __bool__(self): + return self.getCount() > 0 + + __nonzero__ = __bool__ + + def __setattr__(self, key, value): key_adj = self.__key_transform__(key) - if key[0:2] + key[-2:] == '____': + if key[0:1] + key[-1:] == '__': if key.lower() not in self.__my_attrs__.lower().split('|'): raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) else: super(Dict, self).__setattr__(key, value) @@ -85,89 +136,165 @@ def __setattr__(self, key, value): self.setCount(value) # super(CaseInsensitiveDict, self).__setattr__(key, value) # setattr(self, 'Count', value) - elif (hasattr(self, key)): - print "Setting key " + key + ' value... to ' + str(value) + elif (hasattr(self, key)): + # print "Setting key " + key + ' value... to ' + str(value) self[key_adj].setCount(value) - else: super(Counter, self).__setitem__(key_adj, value) + else: + print "Setting attr %s to type %s value %s" % (key_adj, type(value), value) + super(Dict, self).__setitem__(key_adj, value) - # def __setitem__(self, key, value): - # if key[0:2] + key[-2:] == '____': raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) - # else: super(CaseInsensitiveDict, self).__setitem__(self.__key_transform__(key), value) - - # def __str__(self): - # return str(self.getCount()) + '\n' + super(Dict, self).__str__() - - # def __repr__(self): - # return str(self.getCount()) + '\n' + super(Dict, self).__repr__() + def __setitem__(self, name, value): + # print "Setting item %s to type %s value %s" % (name, type(value), value) + super(Dict, self).__setitem__(name, value) - # def __str_base__(self): - # return super(Dict, self).__str__() - - # def __repr_base__(self): - # return super(Dict, self).__repr__() + def __get_summary__(self,level=1,header_only=False): + keys=self.keys() + counts=[Dict(level=level,label=self.label,full_label=self.full_label,value=self.getCount(),is_exclusive_sum=self.__is_exclusive_sum__,class_name=self.__class__.__name__,children=keys)] + if header_only: return counts + for key in keys: + # print "Summaryzing key %s: %s " % (key, type( self[key])) + if key not in self.__my_aggregates__.split("|"): + counts += self[key].__get_summary__(level+1) + return counts + + def __summarize_lines__(self, summary,header=True): + lines=[] + for i, item in enumerate(summary): + exclusive_sum_marker = '*' if item.is_exclusive_sum and len(item.children) > 0 else ' ' + if i is 0 and header: + lines.append("<%s%s:%s:%d>" % (exclusive_sum_marker.strip(), item.class_name, item.full_label, item.value)) + continue + # strr = '%s%d' % (exclusive_sum_marker, item.value) + strr = (' ' * (item.level * 2 - 1) + exclusive_sum_marker + item.label + ':').ljust(16+item.level*2) + lines.append(strr+' ' + str(item.value).rjust(3) + exclusive_sum_marker) + return '\n'.join(lines) def __repr__(self): - strr = "<%s%d>" % ('*' if self.__is_exclusive_sum__ else '', self.getCount() ) - delimit=': ' - if len(self.keys()) > 0: - strr += delimit - delimit='' - delimit_suffix='' - for key in self.keys(): - val = self[key] - is_recursive = hasattr(val, 'keys') and len(val.keys()) - strr += delimit + delimit_suffix - delimit_suffix='' - if is_recursive: strr += '\n { ' - else: strr += ' ' - strr += "%s: %s" % (key, self[key].__repr__().replace('\n', '\n ')) - if is_recursive: - strr += ' }' - delimit_suffix='\n' - delimit=', ' - strr += "" - return strr - + return self.__summarize_lines__(self.__get_summary__()) + def __getitem__(self, key): - # print "Getting " + key adjkey = self.__key_transform__(key) - # if hasattr(self, key.lower()): - # print "I have " + key - - # return super(CaseInsensitiveDict, self).__getitem__(key.lower()) if key == 'Count': return self.getCount() - if adjkey not in self: - if key[0:2] + key[-2:] == '____': - if key.lower() not in self.__my_attrs__.lower().split('|'): return super(Dict, self).__getattr__(key.lower()) + if adjkey not in self: + if key[0:1] + key[-1:] == '__': + if key.lower() not in self.__my_attrs__.lower().split('|'): + try: + return super(Dict, self).__getattr__(key.lower()) + except: + raise(KeyError("Could not find protected item " + key)) return super(Counter, self).__getattr__(key.lower()) - # new_dict = CaseInsensitiveDict() - # new_dict.Count = 0 - # print "New dict for " + key - self[adjkey] = Counter() - self[adjkey].__is_exclusive_sum__ = True - return super(Counter, self).__getitem__(adjkey) + # print "Creating missing item: " + self.parent_label + ('.' if self.parent_label else '') + self.label + ' -> ' + repr(adjkey) + self[adjkey] = Counter(adjkey) + self[adjkey].__label__ = adjkey + self[adjkey].__parent_label__ = self.full_label + self[adjkey].__is_exclusive_sum__ = True + try: + return super(Counter, self).__getitem__(adjkey) + except TypeError: + return "<null>" + # print "Unexpected type of self in __getitem__: " + str(type(self)) + # raise TypeError + # except: + # raise + + +class EvernoteCounter(Counter): + @property + def success(self): + return self.created + self.updated + + @property + def queued(self): + return self.created.queued + self.updated.queued + + @property + def completed(self): + return self.created.completed + self.updated.completed + + @property + def delayed(self): + return self.skipped + self.queued + + @property + def total(self): + return self.getCount() #- self.max - self.max_allowed + + def aggregateSummary(self, includeHeader=True): + aggs = '!max|!+max_allowed|total|+success|++completed|++queued|+delayed' + counts=self.__get_summary__(header_only=True) if includeHeader else [] + parents = [] + last_level=1 + for key_code in aggs.split('|'): + is_exclusive_sum = key_code[0] is not '!' + if not is_exclusive_sum: key_code = key_code[1:] + key = key_code.lstrip('+') + level = len(key_code) - len(key) + 1 + val = self.__getattr__(key) + cls = type(val) + if cls is not int: val = val.getCount() + parent_lbl = '.'.join(parents) + full_label = parent_lbl + ('.' if parent_lbl else '') + key + counts+=[Dict(level=level,label=key,full_label=full_label,value=val,is_exclusive_sum=is_exclusive_sum,class_name=cls,children=['<aggregate>'])] + if level < last_level: del parents[-1] + elif level > last_level: parents.append(key) + last_level = level + return self.__summarize_lines__(counts,includeHeader) + + def fullSummary(self, title='Evernote Counter'): + return '\n'.join( + [self.make_banner(title + ": Summary"), + self.__repr__(), + ' ', + self.make_banner(title + ": Aggregates"), + self.aggregateSummary(False)] + ) + + def __getattr__(self, key): + if hasattr(self, key) and key not in self.keys(): + return getattr(self, key) + return super(EvernoteCounter, self).__getattr__(key) + + def __getitem__(self, key): + # print 'getitem: ' + key + return super(EvernoteCounter, self).__getitem__(key) + +from pprint import pprint + +def test(): + global Counts + Counts = EvernoteCounter() + Counts.unhandled.step(5) + Counts.skipped.step(3) + Counts.error.step() + Counts.updated.completed.step(9) + Counts.created.completed.step(9) + Counts.created.completed.subcount.step(3) + # Counts.updated.completed.subcount = 0 + Counts.created.queued.step() + Counts.updated.queued.step(3) + Counts.max = 150 + Counts.max_allowed = -1 + Counts.print_banner("Evernote Counter: Summary") + print (Counts) + Counts.print_banner("Evernote Counter: Aggregates") + print (Counts.aggregateSummary()) + return + + Counts.print_banner("Evernote Counter") + print Counts + Counts.skipped.step(3) + # Counts.updated.completed.step(9) + # Counts.created.completed.step(9) + Counts.print_banner("Evernote Counter") + print Counts + Counts.error.step() + # Counts.updated.queued.step() + # Counts.created.queued.step(7) + Counts.print_banner("Evernote Counter") + # print Counts + +# test() + + -# Counts = Counter() -# # Counts.Count = 0 -# Counts.Max = 100 -# print "Counts.Max: " + str(Counts ) -# Counts.Current.Updated.setCount(5) -# Counts.Current.Updated.Skipped.setCount(3) -# Counts.Current.Created.setCount(20) -# print "Counts.Current.* " + str(Counts.getCount() ) -# # Counts.max += 1 -# # Counts.Total += 1 -# print "Now, Final Counts: \n" -# print Counts -# # print Counts.New.Skipped + 5 -# print Counts.Current.main_count - - -# print_banner("pprint counts 1") -# pprint( Counts) -# Counts.increment(-7) -# print_banner("pprint counts -= 7") -# pprint( Counts) -# print_banner("pprint counts.current.created.count") -# pprint(Counts.Current.Created.getCount()) \ No newline at end of file diff --git a/anknotes/logging.py b/anknotes/logging.py index 0953d4c..8e91078 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -41,11 +41,14 @@ def show_tooltip(text, time_out=7000, delay=None): tooltip(text, time_out) def counts_as_str(count, max=None): - if max is None: return str(count).center(3) + from anknotes.counters import Counter + if isinstance(count, Counter): count = count.val + if isinstance(max, Counter): max = max.val + if max is None or max <= 0: return str(count).center(3) if count == max: return "All %s" % str(count).center(3) return "Total %s of %s" % (str(count).center(3), str(max).center(3)) -def show_report(title, header=None, log_lines=None, delay=None, log_header_prefix = ' '*5): +def show_report(title, header=None, log_lines=None, delay=None, log_header_prefix = ' '*5, filename=None, blank_line_before=True): if log_lines is None: log_lines = [] if header is None: header = [] lines = [] @@ -55,14 +58,17 @@ def show_report(title, header=None, log_lines=None, delay=None, log_header_prefi lines.append('\t'*level + ('\t\t- ' if lines else '') + line[level:]) if len(lines) > 1: lines[0] += ': ' log_text = '<BR>'.join(lines) - show_tooltip(log_text.replace('\t', '  '), delay=delay) - log_blank() - log(title) + if not header and not log_lines: + i=title.find('> ') + show_tooltip(title[0 if i < 0 else i + 2:], delay=delay) + else: show_tooltip(log_text.replace('\t', '  '*4), delay=delay) + if blank_line_before: log_blank(filename=filename) + log(title, filename=filename) if len(lines) == 1 and not lines[0]: - log(" " + "-" * 187, timestamp=False) + log(" " + "-" * 187, timestamp=False, filename=filename) else: - log(" " + "-" * 187 + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True) - log_blank() + log(" " + "-" * 187 + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True, filename=filename) + log_blank(filename=filename) def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0, cancelButton=False, richText=False, minHeight=None, minWidth=400, styleSheet=None, convertNewLines=True): @@ -193,7 +199,7 @@ def __init__(self, base_path=None, default_filename=None, rm_path=False): else: self.caller_info = caller_name() if self.caller_info: - self.base_path = self.caller_info.Base.replace('.', '\\') + self.base_path = create_log_filename(c.Base) if rm_path: rm_log_path(self.base_path) @@ -469,15 +475,20 @@ def __init__(self, parentframe=None): self.Outer = [f[1] for f in self.__outer__ if f and f[1] and not [exclude for exclude in self.__keywords_exclude__ + [self.Name] if exclude in f[0] or exclude in f[1]]] del parentframe +def create_log_filename(strr): + if strr is None: return "" + strr = strr.replace('.', '\\') + strr = re.sub(r"(^|\\)([^\\]+)\\\2(\b.|\\.|$)", r"\1\2\\", strr) + strr = re.sub(r"^\\*(.+?)\\*$", r"\1", strr) + return strr + # @clockit -def caller_name(skip=None, simplify=True, return_string=False): - if skip is None: - for c in [__caller_name__(i,simplify) for i in range(0,20)]: - if c and c.Base: - return c.Base if return_string else c - return None - c = __caller_name__(skip, simplify=simplify) - return c.Base if return_string else c +def caller_name(skip=None, simplify=True, return_string=False, return_filename=False): + if skip is None: names = [__caller_name__(i,simplify) for i in range(0,20)] + else: names = [__caller_name__(skip, simplify=simplify)] + for c in [c for c in names if c and c.Base]: + return create_log_filename(c.Base) if return_filename else c.Base if return_string else c + return "" if return_filename or return_string else None def __caller_name__(skip=0, simplify=True): """Get a name of a caller in the format module.class.method diff --git a/anknotes/menu.py b/anknotes/menu.py index f69c3cd..ba48df7 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Python Imports from subprocess import * - +from datetime import datetime try: from pysqlite2 import dbapi2 as sqlite except ImportError: @@ -12,7 +12,8 @@ from anknotes.constants import * # Anknotes Main Imports -from anknotes.Controller import Controller +import anknotes.Controller +# from anknotes.Controller import Controller # Anki Imports from aqt.qt import SIGNAL, QMenu, QAction @@ -41,7 +42,7 @@ def anknotes_setup_menu(): ], ["Process &See Also Footer Links [Power Users Only!]", [ - ["Complete All &Steps", see_also], + ["Complete All &Steps", see_also], ["SEPARATOR", None], ["Step &1: Process Anki Notes Without See Also Field", lambda: see_also(1)], ["Step &2: Extract Links from TOC", lambda: see_also(2)], @@ -54,7 +55,7 @@ def anknotes_setup_menu(): ["Step &7: Update See Also Footer In Evernote Notes", lambda: see_also(7)], ["Step &8: Validate and Upload Modified Evernote Notes", lambda: see_also(8)], ["SEPARATOR", None], - ["Step &9: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(9)] + ["Step &9: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(9)] ] ], ["&Maintenance Tasks", @@ -70,7 +71,15 @@ def anknotes_setup_menu(): ] add_menu_items(menu_items) - +def auto_reload_wrapper(function): return lambda: auto_reload_modules(function) + +def auto_reload_modules(function): + if ANKNOTES.DEVELOPER_MODE.ENABLED and ANKNOTES.DEVELOPER_MODE.AUTO_RELOAD_MODULES: + anknotes.shared = reload(anknotes.shared) + if not anknotes.Controller: importlib.import_module('anknotes.Controller') + reload(anknotes.Controller) + function() + def add_menu_items(menu_items, parent=None): if not parent: parent = mw.form.menubar for title, action in menu_items: @@ -83,13 +92,15 @@ def add_menu_items(menu_items, parent=None): else: checkable = False if isinstance(action, dict): - options = action + options = action action = options['action'] if 'checkable' in options: checkable = options['checkable'] + # if ANKNOTES.DEVELOPER_MODE.ENABLED and ANKNOTES.DEVELOPER_MODE.AUTO_RELOAD_MODULES: + action = auto_reload_wrapper(action) menu_action = QAction(_(title), mw, checkable=checkable) - parent.addAction(menu_action) - parent.connect(menu_action, SIGNAL("triggered()"), action) + parent.addAction(menu_action) + parent.connect(menu_action, SIGNAL("triggered()"), action) if checkable: anknotes_checkable_menu_items[title] = menu_action @@ -115,7 +126,7 @@ def import_from_evernote_manual_metadata(guids=None): if not guids: guids = find_evernote_guids(file(FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES, 'r').read()) log("Manually downloading %d Notes" % len(guids)) - controller = Controller() + controller = anknotes.Controller.Controller() controller.forceAutoPage = True controller.currentPage = 1 controller.ManualGUIDs = guids @@ -123,7 +134,7 @@ def import_from_evernote_manual_metadata(guids=None): def import_from_evernote(auto_page_callback=None): - controller = Controller() + controller = anknotes.Controller.Controller() controller.auto_page_callback = auto_page_callback if auto_page_callback: controller.forceAutoPage = True @@ -135,7 +146,7 @@ def import_from_evernote(auto_page_callback=None): def upload_validated_notes(automated=False): - controller = Controller() + controller = anknotes.Controller.Controller() controller.upload_validated_notes(automated) @@ -250,12 +261,12 @@ def external_tool_callback_timer(callback=None): def see_also(steps=None, showAlerts=None, validationComplete=False): - controller = Controller() + controller = anknotes.Controller.Controller() if not steps: steps = range(1, 10) if isinstance(steps, int): steps = [steps] multipleSteps = (len(steps) > 1) if showAlerts is None: showAlerts = not multipleSteps - remaining_steps=steps + remaining_steps=steps if 1 in steps: # Should be unnecessary once See Also algorithms are finalized log(" > See Also: Step 1: Processing Un Added See Also Notes") @@ -299,12 +310,12 @@ def see_also(steps=None, showAlerts=None, validationComplete=False): validate_pending_notes(showAlerts, callback=lambda: see_also(remaining_steps, False, True)) def update_ancillary_data(): - controller = Controller() + controller = anknotes.Controller.Controller() controller.update_ancillary_data() def resync_with_local_db(): - controller = Controller() + controller = anknotes.Controller.Controller() controller.resync_with_local_db() diff --git a/anknotes/settings.py b/anknotes/settings.py index f58c15d..e76ef06 100644 --- a/anknotes/settings.py +++ b/anknotes/settings.py @@ -351,12 +351,12 @@ def setup_evernote(self): # Keep Evernote Tags keep_evernote_tags = QCheckBox(" Save To Anki Note", self) keep_evernote_tags.setChecked( - mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS., SETTINGS.ANKI.TAGS.KEEP_TAGS._DEFAULT_VALUE)) + mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE)) keep_evernote_tags.stateChanged.connect(update_keep_evernote_tags) # Evernote Tags: Tags to Delete evernote_tags_to_delete = QLineEdit() - evernote_tags_to_delete.setText(mw.col.conf.get(SETTINGS.TAGS.TO_DELETE, "")) + evernote_tags_to_delete.setText(mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "")) evernote_tags_to_delete.connect(evernote_tags_to_delete, SIGNAL("textEdited(QString)"), update_evernote_tags_to_delete) @@ -452,7 +452,7 @@ def update_anki_deck_evernote_notebook_integration(): def update_evernote_tags_to_delete(text): - mw.col.conf[SETTINGS.TAGS.TO_DELETE] = text + mw.col.conf[SETTINGS.ANKI.TAGS.TO_DELETE] = text def update_evernote_query_tags(text): @@ -522,7 +522,7 @@ def update_evernote_query_any(): def update_keep_evernote_tags(): - mw.col.conf[SETTINGS.ANKI.TAGS.KEEP_TAGS.] = keep_evernote_tags.isChecked() + mw.col.conf[SETTINGS.ANKI.TAGS.KEEP_TAGS] = keep_evernote_tags.isChecked() evernote_query_text_changed() @@ -723,13 +723,13 @@ def generate_evernote_query(): query_note_title = '"%s"' % query_note_title query += 'intitle:%s ' % query_note_title if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_TAGS, True): - tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).split(",") + tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() for tag in tags: tag = tag.strip() if ' ' in tag: tag = '"%s"' % tag query += 'tag:%s ' % tag if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True): - tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS, '').split(",") + tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS, '').replace(',', ' ').split() for tag in tags: tag = tag.strip() if ' ' in tag: tag = '"%s"' % tag diff --git a/anknotes/shared.py b/anknotes/shared.py index 3edaf64..1b2f8a5 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -66,8 +66,8 @@ class EvernoteQueryLocationType: def get_tag_names_to_import(tagNames, evernoteTags=None, evernoteTagsToDelete=None, keepEvernoteQueryTags=True): if keepEvernoteQueryTags is None: keepEvernoteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, True) if not keepEvernoteQueryTags: return {} if isinstance(tagNames, dict) else [] - if evernoteTags is None: evernoteTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).split(",") - if evernoteTagsToDelete is None: evernoteTagsToDelete = mw.col.conf.get(SETTINGS.TAGS.TO_DELETE, "").split(",") + if evernoteTags is None: evernoteTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() + if evernoteTagsToDelete is None: evernoteTagsToDelete = mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "").replace(',', ' ').split() tags_to_delete = evernoteTags + evernoteTagsToDelete if isinstance(tagNames, dict): return {k: v for k, v in tagNames.items() if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)} diff --git a/anknotes/stopwatch/__init__.py b/anknotes/stopwatch/__init__.py index 4bac39e..98e5d86 100644 --- a/anknotes/stopwatch/__init__.py +++ b/anknotes/stopwatch/__init__.py @@ -8,8 +8,8 @@ import time from anknotes.structs import EvernoteAPIStatus -from anknotes.logging import caller_name, log, log_banner, show_report, counts_as_str -from anknotes.counters import Counter +from anknotes.logging import caller_name, log, log_banner, show_report, counts_as_str, showInfo +from anknotes.counters import Counter, EvernoteCounter """stopwatch is a very simple Python module for measuring time. Great for finding out how long code takes to execute. @@ -40,18 +40,23 @@ def mult(a, b): __author__ = 'Avinash Puchalapalli <http://www.github.com/holycrepe/>' __info__ = 'Forked from stopwatch 0.3.1 by John Paulett <http://blog.7oars.com>' -class TimerCounts(object): - Max, Current, Updated, Added, Queued, Error = (0) * 6 +# class TimerCounts(object): + # Max, Current, Updated, Added, Queued, Error = (0) * 6 class ActionInfo(object): - Label = "" Status = EvernoteAPIStatus.Uninitialized - __created_str__ = "Added to Anki" - __updated_str__ = "Updated in Anki" + __created_str__ = " Added to Anki" + __updated_str__ = " Updated in Anki" + __queued_str__ = " for Upload to Evernote" + + @property + def automated(self): return self.__automated + @property - def ActionShort(self): - if self.__action_short: return self.__action_short - return (self.ActionBase.upper() + 'ING').replace(' OFING', 'ING').replace("CREATEING", "CREATING") + ' ' + self.RowItemBase.upper() + def ActionShort(self): + try: + if self.__action_short: return self.__action_short + finally: return (self.ActionBase.upper() + 'ING').replace(' OFING', 'ING').replace("CREATEING", "CREATING") + ' ' + self.RowItemBase.upper() @property def ActionShortSingle(self): @@ -60,16 +65,16 @@ def ActionShortSingle(self): @property def ActionTemplate(self): if self.__action_template: return self.__action_template - return self.ActionBase + ' of {num} ' + self.RowItemFull.replace('(s)','') + return self.ActionBase + ' ' + self.RowItemFull #.replace('(s)','') @property def ActionBase(self): - return self.ActionTemplate.replace('{num} ', '') + return self.__action_base @property def Action(self): strNum = '' - if self.Max: + if not self.emptyResults: strNum = '%3d ' % self.Max return self.ActionTemplate.replace('(s)', '' if self.Max == 1 else 's').replace('{num} ', strNum) @@ -86,6 +91,7 @@ def RowItemFull(self): def RowItemBase(self): return self.__row_item_base + @property def RowSource(self): if self.__row_source: return self.__row_source return self.RowItemFull @@ -96,7 +102,12 @@ def Label(self): @property def Max(self): - return self.__max + if not self.__max: return -1 + if isinstance(self.__max, Counter): return self.__max.val + return self.__max + + @Max.setter + def Max(self, value): self.__max = value @property def Interval(self): @@ -104,16 +115,17 @@ def Interval(self): @property def emptyResults(self): - return (not self.Max) + return not self.Max or self.Max < 0 @property def willReportProgress(self): - if not self.Max: return False + if self.emptyResults: return False if not self.Interval or self.Interval < 1: return False return self.Max > self.Interval def FormatLine(self, text, num=None): - return text.format(num=('%'+len(str(self.Max))+'d ') % num if num else '', + if isinstance(num, Counter): num = num.val + return text.format(num=('%'+str(len(str(self.Max)))+'d ') % num if num else '', row_sources=self.RowSource.replace('(s)', 's'), rows=self.RowItemFull.replace('(s)', 's'), row=self.RowItemFull.replace('(s)', ''), @@ -125,9 +137,11 @@ def FormatLine(self, text, num=None): def ActionLine(self, title, text,num=None): return " > %s %s: %s" % (self.Action, title, self.FormatLine(text, num)) + @property def Aborted(self): return self.ActionLine("Aborted", "No Qualifying {row_sources} Found") + @property def Initiated(self): return self.ActionLine("Initiated", "{num}{r} Found", self.Max) @@ -138,43 +152,56 @@ def setStatus(self, status): self.Status = status return status - def displayInitialInfo(self,max=None,interval=None, automated=None): - if max: self.__max = max + @property + def enabled(self): return self.__enabled + + def displayInitialInfo(self,max=None,interval=None, automated=None, enabled=None): + if max: + self.__max = max if interval: self.__interval = interval if automated is not None: self.__automated = automated + if enabled is not None: self.__enabled = enabled if self.emptyResults: if not self.automated: - show_report(self.Aborted) + log('report: ' + self.Aborted, self.Label) + show_report(self.Aborted, blank_line_before=False) + else: log('report: [automated] ' + self.Aborted, self.Label) return self.setStatus(EvernoteAPIStatus.EmptyRequest) + if not self.enabled: + log("Not starting - stopwatch.ActionInfo = false ", self.Label) + if not automated: showReport(self.ActionLine("Aborted", "Action has been disabled"), blank_line_before=False) + return self.setStatus(EvernoteAPIStatus.Disabled) log (self.Initiated) if self.willReportProgress: log_banner(self.Action.upper(), self.Label, append_newline=False) return self.setStatus(EvernoteAPIStatus.Initialized) - def __init__(self, action_base='Upload of Validated Evernote Notes', row_item_base=None, row_item_full=None,action_template=None, label=None, auto_label=True, max=None, automated=False, interval=None, row_source=None): + def __init__(self, action_base='Upload of Validated Evernote Notes', row_item_base=None, row_item_full=None,action_template=None, label=None, auto_label=True, max=None, automated=False, enabled=True, interval=None, row_source=None): if label is None and auto_label is True: - label = caller_name() - showInfo(label) + label = caller_name(return_filename=True) if row_item_base is None: actions = action_base.split() - assert len(actions) > 1 action_base = actions[0] - if actions[1] == 'of': - action_base += ' of' - actions = actions[1:] - assert len(actions) > 1 - row_item_base = ' '.join(actions[1:]) - if row_item_full is None and len(actions)>2: - row_item_base = actions[-1] - row_item_full = ' '.join(actions[1:]) + if len(actions) == 1: + action_base = actions[0] + row_item_base = action_base + else: + if actions[1] == 'of': + action_base += ' of' + actions = actions[1:] + assert len(actions) > 1 + row_item_base = ' '.join(actions[1:]) + if row_item_full is None and len(actions)>2: + row_item_base = actions[-1] + row_item_full = ' '.join(actions[1:]) self.__action_base = action_base self.__row_item_base = row_item_base self.__row_item_full = row_item_full self.__row_source = row_source self.__action_template = action_template - self.__action = self.__action_template.replace('{num} ', '') self.__automated=automated - self.__label = label + self.__enabled=enabled + self.__label = label self.__max = max self.__interval = interval @@ -184,13 +211,7 @@ class Timer(object): __stopped = None __start = None __status = EvernoteAPIStatus.Uninitialized - __counts = Counter() - __count = 0 - __count_queued = 0 - __count_error = 0 - __count_created = 0 - __count_updated = 0 - __max = 0 + __counts = EvernoteCounter() __laps = 0 __interval = 100 __parent_timer = None @@ -199,7 +220,7 @@ class Timer(object): @property def counts(self): - return self.__counts__ + return self.__counts @counts.setter def counts(self, value): @@ -209,44 +230,9 @@ def counts(self, value): def laps(self): return len(self.__times) - @property - def max(self): - return self.__max - - @max.setter - def max(self, value): - self.__max = int(value) - - @property - def count_success(self): - return self.count_updated + self.count_created - - @property - def count_queued(self): - return self.__count_queued - @property - def count_created(self): - return self.__count_created - - @property - def count_updated(self): - return self.__count_updated - - @property - def subcount_created(self): - return self.__subcount_created - - @property - def subcount_updated(self): - return self.__subcount_updated - - @property - def count_error(self): - return self.__count_error - @property def is_success(self): - return self.count_success > 0 + return self.counts.success @property def parent(self): @@ -256,6 +242,13 @@ def parent(self): def label(self): if self.info: return self.info.Label return "" + + @label.setter + def label(self,value): + if self.info and isinstance(self.info, ActionInfo): + self.info.__label = value + return + self.__info = ActionInfo(value, label=value) @parent.setter def parent(self, value): @@ -278,7 +271,8 @@ def percentOfParentStr(self): @property def percentComplete(self): - return float(self.count) / self.__max * 100 + if not self.counts.max: return -1 + return float(self.count) / self.counts.max * 100 @property def percentCompleteStr(self): @@ -302,11 +296,12 @@ def rateStrCustom(self, unit=None): @property def count(self): - return self.__count + return max(self.counts.getCount(), 1) @property def projectedTime(self): - return self.__max * self.rateCustom(1) + if not self.counts.max: return -1 + return self.counts.max * self.rateCustom(1) @property def projectedTimeStr(self): @@ -351,6 +346,7 @@ def lap_info(self): @property def isProgressCheck(self): + if not self.counts.max: return False return self.count % max(self.__interval, 1) is 0 @property @@ -362,7 +358,7 @@ def status(self): def status(self, value): if self.hasActionInfo: self.info.Status = value - def autoStep(self, returned_tuple, title=None, update=None, val=None) + def autoStep(self, returned_tuple, title=None, update=None, val=None): self.step(title, val) return self.extractStatus(returned_tuple, update) @@ -371,6 +367,12 @@ def extractStatus(self, returned_tuple, update=None): if len(returned_tuple) == 2: return returned_tuple[1] return returned_tuple[1:] + def checkLimits(self): + if not -1 < self.counts.max_allowed <= self.counts.updated + self.counts.created: return True + log("Count exceeded- Breaking with status " + str(self.status), self.label) + self.reportStatus(EvernoteAPIStatus.ExceededLocalLimit) + return False + def reportStatus(self, status, update=None): """ :type status : EvernoteAPIStatus @@ -379,53 +381,65 @@ def reportStatus(self, status, update=None): if status.IsError: return self.reportError(save_status=False) if status == EvernoteAPIStatus.RequestQueued: return self.reportQueued(save_status=False) if status.IsSuccess: return self.reportSuccess(update, save_status=False) + if status == EvernoteAPIStatus.ExceededLocalLimit: return status + self.counts.unhandled.step() return False + def reportSkipped(self, save_status=True ): + if save_status: self.status = EvernoteAPIStatus.RequestSkipped + return self.counts.skipped.step() + def reportSuccess(self, update=None, save_status=True): if save_status: self.status = EvernoteAPIStatus.Success - if update: self.__count_updated += 1 - else: self.__count_created += 1 - return self.count_success + if update: self.counts.updated.completed.step() + else: self.counts.created.completed.step() + return self.counts.success def reportError(self, save_status=True): if save_status: self.status = EvernoteAPIStatus.GenericError - self.__count_error += 1 - return self.count_error + return self.counts.error.step() - def reportQueued(self, save_status=True): + def reportQueued(self, save_status=True, update=None): if save_status: self.status = EvernoteAPIStatus.RequestQueued - self.__count_queued += 1 - return self.count_queued + if update: return self.counts.updated.queued.step() + return self.counts.created.queued.step() + @property def ReportHeader(self): - return self.info.FormatLine("%s {r} successfully completed" % counts_as_str(self.count, self.max), self.count) + return self.info.FormatLine("%s {r} were processed" % counts_as_str(self.counts.total, self.counts.max), self.count) - def ReportSingle(self, text, count, subtext='', subcount=0) + def ReportSingle(self, text, count, subtext='', queued_text='', queued=0, subcount=0, process_subcounts=True): if not count: return [] + if isinstance(count, Counter) and process_subcounts: + if count.queued: queued = count.queued.val + if count.completed.subcount: subcount = count.completed.subcount.val + if not queued_text: queued_text = self.info.__queued_str__ strs = [self.info.FormatLine("%s {r} %s" % (counts_as_str(count), text), self.count)] - if subcount: strs.append("-%-3d of these were successfully %s " % (subcount, subtext)) + if process_subcounts: + if queued: strs.append("-%-3d of these were queued%s" % (queued, queued_text)) + if subcount: strs.append("-%-3d of these were successfully%s " % (subcount, subtext)) return strs def Report(self, subcount_created=0, subcount_updated=0): str_tips = [] - self.__subcount_created = subcount_created - self.__subcount_updated = subcount_updated - str_tips += self.ReportSingle('were newly created', self.count_created, self.info.__created_str__, subcount_created) - str_tips += self.ReportSingle('already exist and were updated', self.count_updated, self.info.__updated_str__, subcount_updated) - str_tips += self.ReportSingle('were queued', self.count_queued) - if self.count_error: str_tips.append("%d Error(s) occurred " % self.count_error) - show_report(" > %s Complete" % self.info.Action, self.ReportHeader, str_tips) + self.counts.created.completed.subcount = subcount_created + self.counts.updated.completed.subcount = subcount_updated + str_tips += self.ReportSingle('were newly created', self.counts.created, self.info.__created_str__) + str_tips += self.ReportSingle('already exist and were updated', self.counts.updated, self.info.__updated_str__) + str_tips += self.ReportSingle('already exist but were unchanged', self.counts.skipped, process_subcounts=False) + if self.counts.error: str_tips.append("%d Error(s) occurred " % self.counts.error.val) + if self.status == EvernoteAPIStatus.ExceededLocalLimit: str_tips.append("Action was prematurely terminated because locally-defined limit of %d was exceeded." % self.counts.max_allowed.val) + show_report(" > %s Complete" % self.info.Action, self.ReportHeader, str_tips, blank_line_before=False) def step(self, title=None, val=None): - if val is None and unicode(title, 'utf-8', 'ignore').isnumeric(): + if val is None and title.isdigit(): val = title title = None - self.__count += val if self.hasActionInfo and self.isProgressCheck and title: - log( self.info.ActionShortSingle + " %"+str(len('#'+str(self.max)))+"s: %s: %s" % ('#' + str(self.count), self.progress, title), self.label) + fstr = (" %"+str(len('#'+str(self.counts.max.val)))+"s: %s: %s") + log( self.info.ActionShortSingle + (" %"+str(len('#'+str(self.counts.max.val)))+"s: %s: %s") % ('#' + str(self.count), self.progress, title), self.label) return self.isProgressCheck - @property def info(self): """ @@ -438,40 +452,40 @@ def automated(self): if not self.info: return False return self.info.Automated - @property - def emptyResults(self) - return (not max) - def hasActionInfo(self): - return self.info and self.max + return self.info and self.counts.max - def __init__(self, max=None interval=100, info=None, infoStr=None, automated=None, begin=True, label=None): + def __init__(self, max=None, interval=100, info=None, infoStr=None, automated=None, enabled=None, begin=True, label=None, display_initial_info=None, max_allowed=None): """ :type info : ActionInfo """ - simple_label = False - self.__max = 0 if max is None else max + simple_label = False + self.counts.max_allowed = -1 if max_allowed is None else max_allowed self.__interval = interval + if type(info) == str or type(info) == unicode: info = ActionInfo(info) if infoStr and not info: info = ActionInfo(infoStr) if label and not info: simple_label = True + if display_initial_info is None: display_initial_info = False info = ActionInfo(label, label=label) elif label: info.__label = label + if max is not None and info and (info.Max is None or info.Max <= 0): info.Max = max + self.counts.max = -1 if max is None else max self.__info = info self.__action_initialized = False - self.__action_attempted = self.hasActionInfo and not simple_label + self.__action_attempted = self.hasActionInfo and display_initial_info is not False if self.__action_attempted: - self.__action_initialized = info.displayInitialInfo(max=max,interval=interval, automated=automated) + self.__action_initialized = info.displayInitialInfo(max=self.counts.max,interval=interval, automated=automated, enabled=enabled) is EvernoteAPIStatus.Initialized if begin: self.reset() @property def willReportProgress(self): - return self.max > self.interval + return self.counts.max and self.counts.max > self.interval @property def actionInitializationFailed(self): - return self.__action_attempted and not self.__action_initialized + return self.__action_attempted and not self.__action_initialized @property def interval(self): @@ -481,7 +495,7 @@ def start(self): self.reset() def reset(self): - self.__count = self.__count_queued = self.__count_error = self.__count_created = self.__count_updated = 0 + self.counts = EvernoteCounter() if not self.__stopped: self.stop() self.__stopped = None self.__start = self.__time() diff --git a/anknotes/structs.py b/anknotes/structs.py index a2c6d1e..c4abb00 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -291,6 +291,8 @@ class EvernoteAPIStatus(AutoNumberedEnum): """:type : EvernoteAPIStatus""" Manual = -5 """:type : EvernoteAPIStatus""" + RequestSkipped = -4 + """:type : EvernoteAPIStatus""" RequestQueued = -3 """:type : EvernoteAPIStatus""" ExceededLocalLimit = -2 From a8bb3c0d67ede870b24626980621df05df950389 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <github@puchalapalli.com> Date: Sun, 27 Sep 2015 21:13:27 -0400 Subject: [PATCH 25/70] Disabled remove_tags scripts in Anki environment --- anknotes_remove_tags.py | 26 ++++++++++++++++++++++++++ remove_tags.py | 19 ------------------- 2 files changed, 26 insertions(+), 19 deletions(-) create mode 100644 anknotes_remove_tags.py delete mode 100644 remove_tags.py diff --git a/anknotes_remove_tags.py b/anknotes_remove_tags.py new file mode 100644 index 0000000..07b9615 --- /dev/null +++ b/anknotes_remove_tags.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +try: + from pysqlite2 import dbapi2 as sqlite +except ImportError: + from sqlite3 import dbapi2 as sqlite + +try: + from aqt.utils import getText + isAnki = True +except: + isAnki = False + +if not isAnki: + from anknotes.shared import * + + Error = sqlite.Error + ankDBSetLocal() + + tags = ',#Imported,#Anki_Import,#Anki_Import_High_Priority,' + # ankDB().setrowfactory() + dbRows = ankDB().all("SELECT * FROM %s WHERE ? LIKE '%%,' || name || ',%%' " % TABLES.EVERNOTE.TAGS, tags) + + for dbRow in dbRows: + ankDB().execute("UPDATE %s SET tagNames = REPLACE(tagNames, ',%s,', ','), tagGuids = REPLACE(tagGuids, ',%s,', ',') WHERE tagGuids LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, dbRow['name'], dbRow['guid'],dbRow['guid'] )) + ankDB().commit() diff --git a/remove_tags.py b/remove_tags.py deleted file mode 100644 index 3732b00..0000000 --- a/remove_tags.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- - -try: - from pysqlite2 import dbapi2 as sqlite -except ImportError: - from sqlite3 import dbapi2 as sqlite - -from anknotes.shared import * - -Error = sqlite.Error -ankDBSetLocal() - -tags = ',#Imported,#Anki_Import,#Anki_Import_High_Priority,' -# ankDB().setrowfactory() -dbRows = ankDB().all("SELECT * FROM %s WHERE ? LIKE '%%,' || name || ',%%' " % TABLES.EVERNOTE.TAGS, tags) - -for dbRow in dbRows: - ankDB().execute("UPDATE %s SET tagNames = REPLACE(tagNames, ',%s,', ','), tagGuids = REPLACE(tagGuids, ',%s,', ',') WHERE tagGuids LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, dbRow['name'], dbRow['guid'],dbRow['guid'] )) -ankDB().commit() \ No newline at end of file From e9b41c19b75223d82f39e48f2af53082c83733c4 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Sun, 27 Sep 2015 21:28:05 -0400 Subject: [PATCH 26/70] Fix note store check on import --- anknotes/EvernoteImporter.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index 929ac12..211599b 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -178,16 +178,15 @@ def proceed_start(self, auto_paging=False): col.setMod() col.save() lastImportStr = get_friendly_interval_string(lastImport) - if lastImportStr: - lastImportStr = ' [LAST IMPORT: %s]' % lastImportStr + if lastImportStr: lastImportStr = ' [LAST IMPORT: %s]' % lastImportStr log("! > Starting Evernote Import: Page #%d: %-60s%s" % ( self.currentPage, settings.generate_evernote_query(), lastImportStr)) - log( - "-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------", - timestamp=False) + log("-"*181, timestamp=False) if not auto_paging: - if not hasattr(self.evernote, 'noteStore'): + note_store_status = self.evernote.initialize_note_store() + if not note_store_status == EvernoteAPIStatus.Success: log(" > Note store does not exist. Aborting.") + show_tooltip("Could not connect to Evernote servers (Status Code: %s)... Aborting." % note_store_status.name) return False self.evernote.getNoteCount = 0 From b943761609793df98e27bba9ab793b60d2a3e413 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Sun, 27 Sep 2015 22:13:55 -0400 Subject: [PATCH 27/70] Bug fixes that would affect new users --- anknotes/Anki.py | 19 ++++++++----------- anknotes/EvernoteNoteFetcher.py | 9 +++++++-- anknotes/stopwatch/__init__.py | 2 +- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/anknotes/Anki.py b/anknotes/Anki.py index cc2cc12..5d0ae4a 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -44,19 +44,16 @@ def get_deck_name_from_evernote_notebook(self, notebookGuid, deck=None): self.notebook_data = {} if not notebookGuid in self.notebook_data: # log_error("Unexpected error: Notebook GUID '%s' could not be found in notebook data: %s" % (notebookGuid, str(self.notebook_data))) - notebook = ankDB().first( - "SELECT name, stack FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTEBOOKS, notebookGuid)) - if not notebook: - log_error( - " get_deck_name_from_evernote_notebook FATAL ERROR: UNABLE TO FIND NOTEBOOK '%s'. " % notebookGuid) + notebook = EvernoteNotebook(fetch_guid=notebookGuid) + if not notebook.success: + log_error(" get_deck_name_from_evernote_notebook FATAL ERROR: UNABLE TO FIND NOTEBOOK '%s'. " % notebookGuid) return None # log("Getting notebook info: %s" % str(notebook)) - notebook_name, notebook_stack = notebook - self.notebook_data[notebookGuid] = {"stack": notebook_stack, "name": notebook_name} + self.notebook_data[notebookGuid] = notebook notebook = self.notebook_data[notebookGuid] - if notebook['stack']: - deck += u'::' + notebook['stack'] - deck += "::" + notebook['name'] + if notebook.Stack: + deck += u'::' + notebook.Stack + deck += "::" + notebook.Name deck = deck.replace(": ", "::") if deck[:2] == '::': deck = deck[2:] @@ -86,7 +83,7 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang count_update = 0 tmr = stopwatch.Timer(len(evernote_notes), 100, label='AddEvernoteNotes', display_initial_info=False) if tmr.willReportProgress: - log_banner(['ADDING', 'UPDATING'][update] + " %d EVERNOTE NOTES %s ANKI" % (tmr.counts.max, ['TO', 'IN'][update]), tmr.label, append_newline=False) + log_banner(['ADDING', 'UPDATING'][update] + " %d EVERNOTE NOTES %s ANKI" % (tmr.counts.max.val, ['TO', 'IN'][update]), tmr.label, append_newline=False) for ankiNote in evernote_notes: try: title = ankiNote.Title.FullTitle diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index 014850e..1035a48 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -81,8 +81,13 @@ def setNoteTags(self, tag_names=None, tag_guids=None): self.tagNames = [] self.tagGuids = [] return - if not tag_names: tag_names = self.tagNames if self.tagNames else self.result.Note.TagNames if self.result.Note else self.whole_note.tagNames if self.whole_note else None - if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else self.result.Note.TagGuids if self.result.Note else self.whole_note.tagGuids if self.whole_note else None + if not tag_names: + if self.tagNames: tag_names = self.tagNames + if not tag_names and self.result.Note: tag_names = self.result.Note.TagNames + if not tag_names and self.whole_note: tag_names = self.whole_note.tagNames + if not tag_names: tag_names = None + # if not tag_names: tag_names = self.tagNames if self.tagNames else (self.result.Note.TagNames if self.result.Note else (self.whole_note.tagNames if self.whole_note else None)) + if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else (self.result.Note.TagGuids if self.result.Note else (self.whole_note.tagGuids if self.whole_note else None)) self.tagGuids, self.tagNames = self.evernote.get_matching_tag_data(tag_guids, tag_names) def addNoteFromServerToDB(self, whole_note=None, tag_names=None): diff --git a/anknotes/stopwatch/__init__.py b/anknotes/stopwatch/__init__.py index 98e5d86..3950d80 100644 --- a/anknotes/stopwatch/__init__.py +++ b/anknotes/stopwatch/__init__.py @@ -432,7 +432,7 @@ def Report(self, subcount_created=0, subcount_updated=0): show_report(" > %s Complete" % self.info.Action, self.ReportHeader, str_tips, blank_line_before=False) def step(self, title=None, val=None): - if val is None and title.isdigit(): + if val is None and (isinstance(title, str) or isinstance(title, unicode)) and title.isdigit(): val = title title = None if self.hasActionInfo and self.isProgressCheck and title: From 00443c0bd6ee921635c9ad4fc00e453544163d5d Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Mon, 28 Sep 2015 04:46:53 -0400 Subject: [PATCH 28/70] Various bug fixes and improvements --- .gitignore | 1 + anknotes/Anki.py | 26 +-- anknotes/AnkiNotePrototype.py | 12 +- anknotes/Controller.py | 12 +- anknotes/EvernoteImporter.py | 21 +-- anknotes/EvernoteNoteFetcher.py | 16 +- anknotes/EvernoteNotePrototype.py | 5 +- anknotes/EvernoteNotes.py | 6 +- anknotes/ankEvernote.py | 200 ++++++++++++---------- anknotes/constants.py | 4 + anknotes/counters.py | 25 ++- anknotes/db.py | 27 ++- anknotes/detect_see_also_changes.py | 37 ++-- anknotes/error.py | 20 +-- anknotes/logging.py | 27 ++- anknotes/menu.py | 32 ++-- anknotes/shared.py | 11 +- anknotes/stopwatch/__init__.py | 56 ++++-- anknotes/structs.py | 5 +- anknotes/toc.py | 25 +-- anknotes_remove_tags.py | 35 ++-- anknotes_start_detect_see_also_changes.py | 9 +- anknotes_start_find_deleted_notes.py | 9 +- anknotes_start_note_validation.py | 8 +- 24 files changed, 331 insertions(+), 298 deletions(-) diff --git a/.gitignore b/.gitignore index efaf56a..cc1e841 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ anknotes/extra/user/ *.lnk *.py-bkup *.c00k!e +ctags.tags ################# ## Eclipse diff --git a/anknotes/Anki.py b/anknotes/Anki.py index 5d0ae4a..7b2d458 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -25,8 +25,6 @@ import aqt from aqt import mw except: pass -DEBUG_RAISE_API_ERRORS = False - class Anki: def __init__(self): @@ -86,7 +84,7 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang log_banner(['ADDING', 'UPDATING'][update] + " %d EVERNOTE NOTES %s ANKI" % (tmr.counts.max.val, ['TO', 'IN'][update]), tmr.label, append_newline=False) for ankiNote in evernote_notes: try: - title = ankiNote.Title.FullTitle + title = ankiNote.FullTitle content = ankiNote.Content if isinstance(content, str): content = unicode(content, 'utf-8') @@ -107,8 +105,15 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang baseNote = None if update: baseNote = self.get_anki_note_from_evernote_guid(ankiNote.Guid) - if not baseNote: log('Updating note %s: COULD NOT FIND ANKI NOTE ID' % ankiNote.Guid) - assert ankiNote.Tags + if not baseNote: + log_error('Updating note %s: COULD NOT FIND BASE NOTE FOR ANKI NOTE ID' % ankiNote.Guid) + tmr.reportStatus(EvernoteAPIStatus.MissingDataError) + continue + + if ankiNote.Tags is None: + log_error("Could note find tags object for note %s: %s. " % (ankiNote.Guid, ankiNote.FullTitle)) + tmr.reportStatus(EvernoteAPIStatus.MissingDataError) + continue anki_note_prototype = AnkiNotePrototype(self, anki_field_info, ankiNote.TagNames, baseNote, notebookGuid=ankiNote.NotebookGuid, count=tmr.count, count_update=tmr.counts.success, max_count=tmr.counts.max) @@ -387,6 +392,7 @@ def insert_toc_into_see_also(self): extension='htm') for source_guid, source_guid_info in sorted(grouped_results.items(), key=lambda s: s[1][0]): toc_guids = source_guid_info[1] + note_title = source_guid_info[0] ankiNote = self.get_anki_note_from_evernote_guid(source_guid) if not ankiNote: log.dump(toc_guids, 'Missing Anki Note for ' + source_guid, 'insert_toc', timestamp=False) @@ -396,7 +402,7 @@ def insert_toc_into_see_also(self): content_links = find_evernote_links_as_guids(fields[FIELDS.CONTENT]) see_also_links = find_evernote_links_as_guids(see_also_html) new_tocs = set(toc_guids) - set(see_also_links) - set(content_links) - log.dump([new_tocs, toc_guids, see_also_links, content_links], 'TOCs for %s' % fields[FIELDS.TITLE] + ' vs ' + source_guid_title, 'insert_toc_new_tocs', crosspost_to_default=False) + log.dump([new_tocs, toc_guids, see_also_links, content_links], 'TOCs for %s' % fields[FIELDS.TITLE] + ' vs ' + note_title , 'insert_toc_new_tocs', crosspost_to_default=False) new_toc_count = len(new_tocs) if new_toc_count > 0: see_also_count = len(see_also_links) @@ -480,9 +486,9 @@ def insert_toc_and_outline_contents_into_notes(self): "select DISTINCT source_evernote_guid from %s WHERE is_toc = 1 ORDER BY source_evernote_guid ASC" % TABLES.SEE_ALSO) source_guids_count = len(source_guids) i = 0 - for source_evernote_guid in source_guids: + for source_guid in source_guids: i += 1 - note = self.get_anki_note_from_evernote_guid(source_evernote_guid) + note = self.get_anki_note_from_evernote_guid(source_guid) if not note: continue if TAGS.TOC in note.tags: continue for fld in note._model['flds']: @@ -500,7 +506,7 @@ def insert_toc_and_outline_contents_into_notes(self): outline_count = 0 toc_and_outline_links = ankDB().execute( "select target_evernote_guid, is_toc, is_outline from %s WHERE source_evernote_guid = '%s' AND (is_toc = 1 OR is_outline = 1) ORDER BY number ASC" % ( - TABLES.SEE_ALSO, source_evernote_guid)) + TABLES.SEE_ALSO, source_guid)) for target_evernote_guid, is_toc, is_outline in toc_and_outline_links: if target_evernote_guid in linked_notes_fields: linked_note_contents = linked_notes_fields[target_evernote_guid][FIELDS.CONTENT] @@ -523,7 +529,7 @@ def insert_toc_and_outline_contents_into_notes(self): if isinstance(linked_note_contents, str): linked_note_contents = unicode(linked_note_contents, 'utf-8') if (is_toc or is_outline) and (toc_count + outline_count is 0): - log(" > [%3d/%3d] Found TOC/Outline for Note '%s': %s" % (i, source_guids_count, source_evernote_guid, note_title), 'See Also') + log(" > [%3d/%3d] Found TOC/Outline for Note '%s': %s" % (i, source_guids_count, source_guid, note_title), 'See Also') if is_toc: toc_count += 1 if toc_count is 1: diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 24816eb..0542ccd 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -78,7 +78,7 @@ def __init__(self, anki=None, fields=None, tags=None, base_note=None, notebookGu self.Fields = fields self.BaseNote = base_note if enNote and light_processing and not fields: - self.Fields = {FIELDS.TITLE: enNote.Title.FullTitle, FIELDS.CONTENT: enNote.Content, FIELDS.SEE_ALSO: u'', FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + enNote.Guid} + self.Fields = {FIELDS.TITLE: enNote.FullTitle, FIELDS.CONTENT: enNote.Content, FIELDS.SEE_ALSO: u'', FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + enNote.Guid} self.enNote = enNote self.Changed = False self.logged = False @@ -214,7 +214,7 @@ def step_6_process_see_also_links(): see_also_match = regex_see_also().search(self.Fields[FIELDS.CONTENT]) if not see_also_match: if self.Fields[FIELDS.CONTENT].find("See Also") > -1: - log("No See Also Content Found, but phrase 'See Also' exists in " + self.Title.FullTitle + " \n" + self.Fields[FIELDS.CONTENT]) + log("No See Also Content Found, but phrase 'See Also' exists in " + self.FullTitle + " \n" + self.Fields[FIELDS.CONTENT]) raise ValueError return self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group(0), see_also_match.group('Suffix')) @@ -427,11 +427,11 @@ def update_note(self): if not self.OriginalGuid: flds = get_dict_from_list(self.BaseNote.items()) self.OriginalGuid = get_evernote_guid_from_anki_fields(flds) - old_title = ankDB().scalar( + db_title = ankDB().scalar( "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.OriginalGuid)) do_log_title=False new_guid = self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') - new_title = self.Fields[FIELD.TITLE] + new_title = self.Fields[FIELDS.TITLE] old_title = db_title if not isinstance(new_title, unicode): try: new_title = unicode(new_title, 'utf-8') @@ -458,6 +458,10 @@ def Title(self): title = self.originalFields[FIELDS.TITLE] return EvernoteNoteTitle(title) + @property + def FullTitle(self): return self.Title.FullTitle + + def add_note(self): self.create_note() if self.note is not None: diff --git a/anknotes/Controller.py b/anknotes/Controller.py index 36faaf0..bd94363 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -14,6 +14,7 @@ ### Anknotes Class Imports from anknotes.AnkiNotePrototype import AnkiNotePrototype +from anknotes.EvernoteNotePrototype import EvernoteNotePrototype from anknotes.EvernoteNoteTitle import generateTOCTitle from anknotes import stopwatch @@ -33,7 +34,6 @@ ### Anki Imports from aqt import mw -DEBUG_RAISE_API_ERRORS = False # load_time = datetime.now() # log("Loaded controller at " + load_time.isoformat(), 'import') class Controller: @@ -97,13 +97,13 @@ def upload_validated_notes(self, automated=False): else: notes_created.append(note) queries2.append([rootTitle, contents]) - else: did_break=False + else: tmr.reportNoBreak() tmr.Report(self.anki.add_evernote_notes(notes_created) if tmr.counts.created else 0, self.anki.update_evernote_notes(notes_updated) if tmr.counts.updated else 0) - if tmr.counts.created.anki: ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries2) - if tmr.counts.updated.anki: ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries1) + if tmr.counts.created.completed.subcount: ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries2) + if tmr.counts.updated.completed.subcount: ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries1) if tmr.is_success: ankDB().commit() - if did_break and tmr.status != EvernoteAPIStatus.ExceededLocalLimit: mw.progress.timer((30 if tmr.status.IsDelayableError else EVERNOTE.UPLOAD.RESTART_INTERVAL) * 1000, lambda: self.upload_validated_notes(True), False) - return tmr.status, tmr.counts, 0 + if tmr.should_retry: mw.progress.timer((30 if tmr.status.IsDelayableError else EVERNOTE.UPLOAD.RESTART_INTERVAL) * 1000, lambda: self.upload_validated_notes(True), False) + return tmr.status, tmr.count, 0 def create_auto_toc(self): def check_old_values(): diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index 211599b..3427853 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -32,8 +32,6 @@ try: from aqt import mw except: pass -DEBUG_RAISE_API_ERRORS = False - class EvernoteImporter: forceAutoPage = False @@ -92,21 +90,16 @@ def get_evernote_metadata(self): :type: NotesMetadataList """ except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - self.MetadataProgress.Status = EvernoteAPIStatus.RateLimitError - return False - raise + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + self.MetadataProgress.Status = EvernoteAPIStatus.RateLimitError + return False except socket.error, v: - if HandleSocketError(v, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - self.MetadataProgress.Status = EvernoteAPIStatus.SocketError - return False - raise - + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + self.MetadataProgress.Status = EvernoteAPIStatus.SocketError + return False self.MetadataProgress.loadResults(result) self.evernote.metadata = self.MetadataProgress.NotesMetadata - log(" - Metadata Results: %s" % self.MetadataProgress.Summary, timestamp=False) + log(" " * 32 + "- Metadata Results: %s" % self.MetadataProgress.Summary, timestamp=False) return True def update_in_anki(self, evernote_guids): diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index 1035a48..891ec23 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -137,17 +137,13 @@ def getNoteRemoteAPICall(self): False, False) """:type : evernote.edam.type.ttypes.Note""" except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - self.reportResult(EvernoteAPIStatus.RateLimitError) - if DEBUG_RAISE_API_ERRORS: raise - return False - raise + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + self.reportResult(EvernoteAPIStatus.RateLimitError) + return False except socket.error, v: - if HandleSocketError(v, api_action_str): - self.reportResult(EvernoteAPIStatus.SocketError) - if DEBUG_RAISE_API_ERRORS: raise - return False - raise + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + self.reportResult(EvernoteAPIStatus.SocketError) + return False assert self.whole_note.guid == self.evernote_guid return True diff --git a/anknotes/EvernoteNotePrototype.py b/anknotes/EvernoteNotePrototype.py index 997224d..9541c46 100644 --- a/anknotes/EvernoteNotePrototype.py +++ b/anknotes/EvernoteNotePrototype.py @@ -55,7 +55,7 @@ def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid= db_note_keys = db_note.keys() for key in ['content', 'guid', 'notebookGuid', 'updateSequenceNum', 'tagNames', 'tagGuids']: if not key in db_note_keys: - log_error("FATAL ERROR: Unable to find key %s in db note %s! \n%s" % (key, self.Title.FullTitle, db_note_keys)) + log_error("FATAL ERROR: Unable to find key %s in db note %s! \n%s" % (key, self.FullTitle, db_note_keys)) log("Values: \n\n" + str({k: db_note[k] for k in db_note_keys }), 'EvernoteNotePrototypeInit') else: setattr(self, upperFirst(key), db_note[key]) @@ -92,8 +92,7 @@ def Depth(self): return self.Title.Depth @property - def FullTitle(self): - return self.Title.FullTitle + def FullTitle(self): return self.Title.FullTitle @property def Name(self): diff --git a/anknotes/EvernoteNotes.py b/anknotes/EvernoteNotes.py index 67052e4..2b9f468 100644 --- a/anknotes/EvernoteNotes.py +++ b/anknotes/EvernoteNotes.py @@ -159,8 +159,8 @@ def processNote(self, enNote): assert enNote.Title.Base childBaseTitleStr = enNote.Title.Base.FullTitle if childBaseTitleStr in self.RootNotesMissing.ChildTitlesDict[rootTitleStr]: - log_dump(self.RootNotesMissing.ChildTitlesDict[rootTitleStr], repr(enNote)) - assert not childBaseTitleStr in self.RootNotesMissing.ChildTitlesDict[rootTitleStr] + log_error("Duplicate Child Base Title String. \n%-18s%s\n%-18s%s: %s\n%-18s%s" % ('Root Note Title: ', rootTitleStr, 'Child Note: ', enNote.Guid, childBaseTitleStr, 'Duplicate Note: ', self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitleStr]), crosspost_to_default=False) + log(" > WARNING: Duplicate Child Notes: " + enNote.FullTitle) self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitleStr] = enNote.Guid self.RootNotesMissing.ChildNotesDict[rootTitleStr][enNote.Guid] = enNote if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: @@ -348,7 +348,7 @@ def processAllRootNotesMissing(self): notebookGuids[enChildNote.NotebookGuid] += 1 level = enChildNote.Title.Level # childName = enChildNote.Title.Name - # childTitle = enChildNote.Title.FullTitle + # childTitle = enChildNote.FullTitle log(" %2d: %d. --> %-60s" % (count_child, level, childBaseTitle), 'RootTitles-TOC', timestamp=False) # tocList.generateEntry(childTitle, enChildNote) diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 7bbf521..75554e1 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -2,6 +2,7 @@ ### Python Imports import socket import stopwatch +import sys from datetime import datetime, timedelta from StringIO import StringIO @@ -12,11 +13,7 @@ except ImportError: eTreeImported = False -try: - from aqt.utils import openLink, getText, showInfo - inAnki = True -except ImportError: - inAnki = False +inAnki='anki' in sys.modules try: from pysqlite2 import dbapi2 as sqlite @@ -36,6 +33,8 @@ from anknotes.evernote.edam.type.ttypes import Note as EvernoteNote from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException from anknotes.evernote.api.client import EvernoteClient + + from aqt.utils import openLink, getText, showInfo ### Anki Imports # import anki @@ -57,6 +56,9 @@ class Evernote(object): """:type : dict[str, anknotes.structs.EvernoteTag]""" DTD = None hasValidator = None + token = None + client = None + """:type : EvernoteClient """ def __init__(self): global eTreeImported, dbLocal @@ -65,8 +67,12 @@ def __init__(self): self.noteStore = None self.getNoteCount = 0 self.hasValidator = eTreeImported - if ankDBIsLocal(): + if ankDBIsLocal(): + log("Skipping Evernote client load (DB is Local)", 'client') return + self.setup_client() + + def setup_client(self): auth_token = mw.col.conf.get(SETTINGS.EVERNOTE.AUTH_TOKEN, False) if not auth_token: # First run of the Plugin we did not save the access key yet @@ -86,26 +92,27 @@ def __init__(self): request_token.get('oauth_token_secret'), oauth_verifier) mw.col.conf[SETTINGS.EVERNOTE.AUTH_TOKEN] = auth_token + else: client = EvernoteClient(token=auth_token, sandbox=EVERNOTE.API.IS_SANDBOXED) self.token = auth_token - self.client = EvernoteClient(token=auth_token, sandbox=EVERNOTE.API.IS_SANDBOXED) - + self.client = client + log("Set up Evernote Client", 'client') + def initialize_note_store(self): if self.noteStore: return EvernoteAPIStatus.Success - api_action_str = u'trying to initialize the Evernote Client.' + api_action_str = u'trying to initialize the Evernote Note Store.' log_api("get_note_store") + if not self.client: + log_error("Client does not exist for some reason. Did we not initialize Evernote Class? Current token: " + str(self.token)) + self.setup_client() try: self.noteStore = self.client.get_note_store() except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return EvernoteAPIStatus.RateLimitError - raise + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return EvernoteAPIStatus.RateLimitError except socket.error, v: - if HandleSocketError(v, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return EvernoteAPIStatus.SocketError - raise + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return EvernoteAPIStatus.SocketError return EvernoteAPIStatus.Success def validateNoteBody(self, noteBody, title="Note Body"): @@ -218,7 +225,7 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot :returns Status and Note """ if enNote: - noteTitle = enNote.Title.FullTitle + noteTitle = enNote.FullTitle noteContents = enNote.Content tagNames = enNote.Tags if enNote.NotebookGuid: parentNotebook = enNote.NotebookGuid @@ -272,13 +279,11 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot try: note = getattr(self.noteStore, callType + 'Note')(self.token, ourNote) except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return EvernoteAPIStatus.RateLimitError, None + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return EvernoteAPIStatus.RateLimitError, None except socket.error, v: - if HandleSocketError(v, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return EvernoteAPIStatus.SocketError, None + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return EvernoteAPIStatus.SocketError, None except EDAMUserException, edue: ## Something was wrong with the note data ## See EDAMErrorCode enumeration for error code explanation @@ -289,7 +294,7 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot log_error(str(ourNote.tagNames)) log_error(str(ourNote.content)) log_error("-------------------------------------------------\r\n") - if DEBUG_RAISE_API_ERRORS: raise + if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return EvernoteAPIStatus.UserError, None except EDAMNotFoundException, ednfe: print "EDAMNotFoundException:", ednfe @@ -300,7 +305,7 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot if ourNote.notebookGuid: log_error(str(ourNote.notebookGuid)) log_error("-------------------------------------------------\r\n") - if DEBUG_RAISE_API_ERRORS: raise + if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return EvernoteAPIStatus.NotFoundError, None except Exception, e: print "Unknown Exception:", e @@ -345,31 +350,43 @@ def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): return fetcher.results def check_ancillary_data_up_to_date(self): - if not self.check_tags_up_to_date(): - self.update_tags_db("Tags were not up to date when checking ancillary data") - if not self.check_notebooks_up_to_date(): - self.update_notebook_db() + new_tags = 0 if self.check_tags_up_to_date() else self.update_tags_database("Tags were not up to date when checking ancillary data") + new_nbs = 0 f self.check_notebooks_up_to_date() else self.update_notebooks_database() + self.report_ancillary_data_results(new_tags, new_nbs) def update_ancillary_data(self): - self.update_tags_db("Manual call to update ancillary data") - self.update_notebook_db() + new_tags = self.update_tags_database("Manual call to update ancillary data") + new_nbs = self.update_notebooks_database() + self.report_ancillary_data_results(new_tags, new_nbs) + @staticmethod + def report_ancillary_data_results(new_tags, new_nbs): + if new_tags is 0 and new_nbs is 0: strr = 'No new tags or notebooks found' + elif new_tags is 0: strr = '%d new notebook%s found' % (new_nbs, '' if new_nbs is 1 else 's') + elif new_nbs is 0: strr = '%d new tag%s found' % (new_tags, '' if new_tags is 1 else 's') + else: strr = '%d new tag%s and %d new notebook%s found' % (new_tags, '' if new_tags is 1 else 's', new_nbs, '' if new_nbs is 1 else 's') + show_tooltip("Update of ancillary data complete: " + strr, do_log=True) + + def set_notebook_data(self): + if not hasattr(self, 'notebook_data') or not self.notebook_data or len(self.notebook_data.keys()) == 0: + self.notebook_data = {x['guid']: EvernoteNotebook(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS)} + def check_notebook_metadata(self, notes): """ :param notes: :type : list[EvernoteNotePrototype] :return: """ - if not hasattr(self, 'notebook_data'): - self.notebook_data = {x.guid:{'stack': x.stack, 'name': x.name} for x in ankDB().execute("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS) } + self.set_notebook_data() for note in notes: assert(isinstance(note, EvernoteNotePrototype)) - if not note.NotebookGuid in self.notebook_data: - self.update_notebook_db() - if not note.NotebookGuid in self.notebook_data: - log_error("FATAL ERROR: Notebook GUID %s for Note %s: %s does not exist on Evernote servers" % (note.NotebookGuid, note.Guid, note.Title)) - raise EDAMNotFoundException() - return False + if note.NotebookGuid in self.notebook_data: continue + new_nbs = self.update_notebooks_database() + if note.NotebookGuid in self.notebook_data: + log("Missing notebook GUID %s for note %s when checking notebook metadata. Notebook was found after updating Anknotes' notebook database." + '' if new_nbs < 1 else ' In total, %d new notebooks were found.' % new_nbs) + continue + log_error("FATAL ERROR: Notebook GUID %s for Note %s: %s does not exist on Evernote servers" % (note.NotebookGuid, note.Guid, note.Title)) + raise EDAMNotFoundException() return True def check_notebooks_up_to_date(self): @@ -378,94 +395,80 @@ def check_notebooks_up_to_date(self): notebookGuid = note_metadata.notebookGuid if not notebookGuid: log_error(" > Notebook check: Unable to find notebook guid for '%s'. Returned '%s'. Metadata: %s" % ( - evernote_guid, str(notebookGuid), str(note_metadata)), crossPost=False) + evernote_guid, str(notebookGuid), str(note_metadata)), crosspost_to_default=False) elif notebookGuid not in self.notebook_data: - nb = EvernoteNotebook(fetch_guid=notebookGuid) - if not nb.success: + notebook = EvernoteNotebook(fetch_guid=notebookGuid) + if not notebook.success: log(" > Notebook check: Missing notebook guid '%s'. Will update with an API call." % notebookGuid) return False - self.notebook_data[notebookGuid] = nb + self.notebook_data[notebookGuid] = notebook return True - def update_notebook_db(self): + def update_notebooks_database(self): self.initialize_note_store() api_action_str = u'trying to update Evernote notebooks.' log_api("listNotebooks") - try: + try: notebooks = self.noteStore.listNotebooks(self.token) + """: type : list[evernote.edam.type.ttypes.Notebook] """ except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return None - raise + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return None except socket.error, v: - if HandleSocketError(v, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return None - raise + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return None data = [] + self.notebook_data = {} for notebook in notebooks: self.notebook_data[notebook.guid] = {"stack": notebook.stack, "name": notebook.name} data.append( [notebook.guid, notebook.name, notebook.updateSequenceNum, notebook.serviceUpdated, notebook.stack]) - ankDB().execute("DROP TABLE %s " % TABLES.EVERNOTE.NOTEBOOKS) - ankDB().InitNotebooks(True) - log_dump(data, 'update_notebook_db table data') - ankDB().executemany( + db = ankDB() + old_count = db.scalar("SELECT COUNT(*) FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS) + db.execute("DROP TABLE %s " % TABLES.EVERNOTE.NOTEBOOKS) + db.InitNotebooks(True) + # log_dump(data, 'update_notebooks_database table data', crosspost_to_default=False) + db.executemany( "INSERT INTO `%s`(`guid`,`name`,`updateSequenceNum`,`serviceUpdated`, `stack`) VALUES (?, ?, ?, ?, ?)" % TABLES.EVERNOTE.NOTEBOOKS, data) - log_dump(ankDB().all("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS), 'sql data') + db.commit() + # log_dump(ankDB().all("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS), 'sql data', crosspost_to_default=False) + return len(self.notebook_data) - old_count - def check_tags_up_to_date(self): - for evernote_guid in self.evernote_guids: - if evernote_guid not in self.metadata: - log_error('Could not find note metadata for Note ''%s''' % evernote_guid) - return False - else: - note_metadata = self.metadata[evernote_guid] - if not note_metadata.tagGuids: continue - for tag_guid in note_metadata.tagGuids: - if tag_guid not in self.tag_data: - tag = EvernoteTag(fetch_guid=tag_guid) - if not tag.success: - return False - self.tag_data[tag_guid] = tag - return True - - def update_tags_db(self, reason_str=''): + def update_tags_database(self, reason_str=''): if hasattr(self, 'LastTagDBUpdate') and datetime.now() - self.LastTagDBUpdate < timedelta(minutes=15): return None self.LastTagDBUpdate = datetime.now() self.initialize_note_store() api_action_str = u'trying to update Evernote tags.' - log_api("listTags" + (': ' + reason_str) if reason_str else '') - + log_api("listTags" + (': ' + reason_str) if reason_str else '') try: tags = self.noteStore.listTags(self.token) """: type : list[evernote.edam.type.ttypes.Tag] """ except EDAMSystemException as e: - if HandleEDAMRateLimitError(e, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return None - raise + if not HandleEDAMRateLimitError(e, api_action_str): raise + if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return None except socket.error, v: - if HandleSocketError(v, api_action_str): - if DEBUG_RAISE_API_ERRORS: raise - return None - raise + if not HandleSocketError(v, api_action_str): raise + if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return None data = [] - if not hasattr(self, 'tag_data'): self.tag_data = {} + self.tag_data = {} enTag = None for tag in tags: enTag = EvernoteTag(tag) self.tag_data[enTag.Guid] = enTag data.append(enTag.items()) if not enTag: return None + db=ankDB() + old_count=db.scalar("SELECT COUNT(*) FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS) ankDB().execute("DROP TABLE %s " % TABLES.EVERNOTE.TAGS) ankDB().InitTags(True) ankDB().executemany(enTag.sqlUpdateQuery(), data) ankDB().commit() - + return len(self.tag_data) - old_count + def set_tag_data(self): if not hasattr(self, 'tag_data') or not self.tag_data or len(self.tag_data.keys()) == 0: self.tag_data = {x['guid']: EvernoteTag(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS)} @@ -486,7 +489,7 @@ def get_matching_tag_data(self, tag_guids=None, tag_names=None): from_guids = True if (tag_guids is not None) else False tags_original = tag_guids if from_guids else tag_names if self.get_missing_tags(tags_original, from_guids): - self.update_tags_db("Missing Tag %s(s) Were found when attempting to get matching tag data" % ('Guids' if from_guids else 'Names')) + self.update_tags_database("Missing Tag %s(s) Were found when attempting to get matching tag data" % ('Guids' if from_guids else 'Names')) missing_tags = self.get_missing_tags(tags_original, from_guids) if missing_tags: log_error("FATAL ERROR: Tag %s(s) %s were not found on the Evernote Servers" % ('Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)))) @@ -502,7 +505,18 @@ def get_matching_tag_data(self, tag_guids=None, tag_names=None): tagGuids.append(k) tagNames.append(v.Name if is_struct else v) tagNames = sorted(tagNames, key=lambda s: s.lower()) - return tagGuids, tagNames - - -DEBUG_RAISE_API_ERRORS = False + return tagGuids, tagNames + + def check_tags_up_to_date(self): + for evernote_guid in self.evernote_guids: + if evernote_guid not in self.metadata: + log_error('Could not find note metadata for Note ''%s''' % evernote_guid) + return False + note_metadata = self.metadata[evernote_guid] + if not note_metadata.tagGuids: continue + for tag_guid in note_metadata.tagGuids: + if tag_guid in self.tag_data: continue + tag = EvernoteTag(fetch_guid=tag_guid) + if not tag.success: return False + self.tag_data[tag_guid] = tag + return True diff --git a/anknotes/constants.py b/anknotes/constants.py index e5159c3..59c2f32 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -127,8 +127,12 @@ class VALIDATION: ENABLED = True AUTOMATED = True class API: + class RateLimitErrorHandling: + IgnoreError, ToolTipError, AlertError = range(3) CONSUMER_KEY = "holycrepe" IS_SANDBOXED = False + EDAM_RATE_LIMIT_ERROR_HANDLING = RateLimitErrorHandling.ToolTipError + DEBUG_RAISE_ERRORS = False class TABLES: SEE_ALSO = "anknotes_see_also" diff --git a/anknotes/counters.py b/anknotes/counters.py index 934b239..fa8ff4c 100644 --- a/anknotes/counters.py +++ b/anknotes/counters.py @@ -1,12 +1,13 @@ -from addict import Dict import os +import sys from pprint import pprint -absolutely_unused_variable = os.system("cls") +from addict import Dict +inAnki='anki' in sys.modules def print_banner(title): - print "-" * 40 + print "-" * max(40, len(title) + 5) print title - print "-" * 40 + print "-" * max(40, len(title) + 5) class Counter(Dict): @@ -15,7 +16,7 @@ def print_banner(self, title): @staticmethod def make_banner(title): - return '\n'.join(["-" * 40, title ,"-" * 40]) + return '\n'.join(["-" * max(40, len(title) + 5), title ,"-" * max(40, len(title) + 5)]) def __init__(self, *args, **kwargs): self.setCount(0) @@ -25,6 +26,11 @@ def __init__(self, *args, **kwargs): self.__is_exclusive_sum__ = True # return super(Counter, self).__init__(*args, **kwargs) + def reset(self, keys_to_keep=None): + if keys_to_keep is None: keys_to_keep=self.__my_aggregates__.lower().split("|") + for key in self.keys(): + if key.lower() not in keys_to_keep: del self[key] + def __key_transform__(self, key): for k in self.keys(): if k.lower() == key.lower(): return k @@ -260,8 +266,10 @@ def __getitem__(self, key): from pprint import pprint -def test(): +def test(): global Counts + absolutely_unused_variable = os.system("cls") + del absolutely_unused_variable Counts = EvernoteCounter() Counts.unhandled.step(5) Counts.skipped.step(3) @@ -279,6 +287,9 @@ def test(): Counts.print_banner("Evernote Counter: Aggregates") print (Counts.aggregateSummary()) + Counts.reset() + + print Counts.fullSummary('Reset Counter') return Counts.print_banner("Evernote Counter") @@ -294,7 +305,7 @@ def test(): Counts.print_banner("Evernote Counter") # print Counts -# test() +if not inAnki and 'anknotes' not in sys.modules: test() diff --git a/anknotes/db.py b/anknotes/db.py index 88ac728..161f620 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -14,16 +14,17 @@ ankNotesDBInstance = None dbLocal = False +def anki_profile_path_root(): + return os.path.abspath(os.path.join(os.path.dirname(PATH), '..' + os.path.sep)) + def last_anki_profile_name(): - anki_profile_path_root = os.path.abspath(os.path.join(os.path.dirname(PATH), '..' + os.path.sep)) - print anki_profile_path_root - name = ANKI.PROFILE_NAME - if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name - if os.path.isfile(FILES.USER.LAST_PROFILE_LOCATION): name = file(FILES.USER.LAST_PROFILE_LOCATION, 'r').read().strip() - if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name + root = anki_profile_path_root() name = ANKI.PROFILE_NAME - if name and os.path.isdir(os.path.join(anki_profile_path_root, name)): return name - dirs = [x for x in os.listdir(anki_profile_path_root) if os.path.isdir(os.path.join(anki_profile_path_root, x)) and x is not 'addons'] + if name and os.path.isdir(os.path.join(root, name)): return name + if os.path.isfile(FILES.USER.LAST_PROFILE_LOCATION): + name = file(FILES.USER.LAST_PROFILE_LOCATION, 'r').read().strip() + if name and os.path.isdir(os.path.join(root, name)): return name + dirs = [x for x in os.listdir(root) if os.path.isdir(os.path.join(root, x)) and x is not 'addons'] if not dirs: return "" return dirs[0] @@ -39,10 +40,8 @@ def ankDBIsLocal(): def ankDB(reset=False): global ankNotesDBInstance, dbLocal if not ankNotesDBInstance or reset: - if dbLocal: - ankNotesDBInstance = ank_DB( os.path.abspath(os.path.join(PATH, '..' + os.path.sep , '..' + os.path.sep , last_anki_profile_name() + os.path.sep, 'collection.anki2'))) - else: - ankNotesDBInstance = ank_DB() + if dbLocal: ankNotesDBInstance = ank_DB( os.path.abspath(os.path.join(anki_profile_path_root(), last_anki_profile_name(), 'collection.anki2'))) + else: ankNotesDBInstance = ank_DB() return ankNotesDBInstance @@ -71,7 +70,7 @@ class ank_DB(object): def __init__(self, path=None, text=None, timeout=0): encpath = path if isinstance(encpath, unicode): - encpath = path.encode("utf-8") + encpath = path.encode("utf-8") if path: self._db = sqlite.connect(encpath, timeout=timeout) self._db.row_factory = sqlite.Row @@ -81,7 +80,7 @@ def __init__(self, path=None, text=None, timeout=0): else: self._db = mw.col.db._db self._path = mw.col.db._path - self._db.row_factory = sqlite.Row + self._db.row_factory = sqlite.Row self.echo = os.environ.get("DBECHO") self.mod = False diff --git a/anknotes/detect_see_also_changes.py b/anknotes/detect_see_also_changes.py index 7c6a9ca..7545ce6 100644 --- a/anknotes/detect_see_also_changes.py +++ b/anknotes/detect_see_also_changes.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import shutil +import sys try: from pysqlite2 import dbapi2 as sqlite @@ -8,9 +9,6 @@ from anknotes.shared import * from anknotes import stopwatch -# from anknotes.stopwatch import clockit -import re -from anknotes._re import __Match from anknotes.EvernoteNotePrototype import EvernoteNotePrototype from anknotes.AnkiNotePrototype import AnkiNotePrototype @@ -91,7 +89,7 @@ class see_also_match(object): __subject__ = None __content__ = None __matchobject__ = None - """:type : __Match """ + """:type : anknotes._re.__Match """ __match_attempted__ = 0 @property @@ -124,7 +122,7 @@ def successful_match(self): if self.__matchobject__: return True if self.__match_attempted__ is 0 and self.subject is not None: self.__matchobject__ = notes.rgx.search(self.subject) - """:type : __Match """ + """:type : anknotes._re.__Match """ self.__match_attempted__ += 1 return self.__matchobject__ is not None @@ -148,7 +146,7 @@ def __init__(self, content=None): self.__content__ = content self.__match_attempted__ = 0 self.__matchobject__ = None - """:type : __Match """ + """:type : anknotes._re.__Match """ content = pstrings() see_also = pstrings() old = version() @@ -231,17 +229,17 @@ def process_note(): # SELECT DISTINCT s.target_evernote_guid, n.* FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid AND n.tagNames NOT LIKE '%,#TOC,%' AND n.tagNames NOT LIKE '%,#Outline,%' ORDER BY n.title ASC; sql = "SELECT DISTINCT s.target_evernote_guid, n.* FROM %s as s, %s as n WHERE s.target_evernote_guid = n.guid AND n.tagNames NOT LIKE '%%,%s,%%' AND n.tagNames NOT LIKE '%%,%s,%%' ORDER BY n.title ASC;" results = ankDB().all(sql % (TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES, TAGS.TOC, TAGS.OUTLINE)) - count_queued = 0 - tmr = stopwatch.Timer(len(results), 25, label='SeeAlso-Step7') + # count_queued = 0 + tmr = stopwatch.Timer(len(results), 25, 'Updating See Also Notes', label='SeeAlso-Step7', display_initial_info=False) log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), do_print=True) log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), tmr.label) notes_updated=[] - number_updated = 0 + # number_updated = 0 for result in results: enNote = EvernoteNotePrototype(db_note=result) n = notes() if tmr.step(): - log.go("Note %5s: %s: %s" % ('#' + str(tmr.count), tmr.progress, enNote.FullTitle if enNote.Status.IsSuccess else '(%s)' % enNote.Guid), , do_print=True, print_timestamp=False) + log.go("Note %5s: %s: %s" % ('#' + str(tmr.count), tmr.progress, enNote.FullTitle if enNote.Status.IsSuccess else '(%s)' % enNote.Guid), do_print=True, print_timestamp=False) flds = ankDB().scalar("SELECT flds FROM notes WHERE flds LIKE '%%%s%s%%'" % (FIELDS.EVERNOTE_GUID_PREFIX, enNote.Guid)).split("\x1f") n.new.see_also = notes.version.pstrings(flds[FIELDS.ORD.SEE_ALSO]) result = process_note() @@ -259,17 +257,16 @@ def process_note(): print_results('Diff\\Contents', final=True) enNote.Content = n.new.content.final if not evernote: evernote = Evernote() - status, whole_note = evernote.makeNote(enNote=enNote) + whole_note = tmr.autoStep(evernote.makeNote(enNote=enNote), enNote.FullTitle, True) if tmr.reportStatus(status) == False: raise ValueError - if status.IsDelayableError: break - if status.IsSuccess: notes_updated.append(EvernoteNotePrototype(whole_note=whole_note)) - if tmr.count_success > 0: - if not anki: anki = Anki() - number_updated = anki.update_evernote_notes(notes_updated) - log.go("Total %d of %d note(s) successfully uploaded to Evernote" % (tmr.count_success, tmr.max), tmr.label, do_print=True) - if number_updated > 0: log.go(" > %4d updated in Anki" % number_updated, tmr.label, do_print=True) - if tmr.count_queued > 0: log.go(" > %4d queued for validation" % tmr.count_queued, tmr.label, do_print=True) - if tmr.count_error > 0: log.go(" > %4d error(s) occurred" % tmr.count_error, tmr.label, do_print=True) + if tmr.status.IsDelayableError: break + if tmr.status.IsSuccess: notes_updated.append(EvernoteNotePrototype(whole_note=whole_note)) + if tmr.is_success and not anki: anki = Anki() + tmr.Report(0, anki.update_evernote_notes(notes_updated) if tmr.is_success else 0) + # log.go("Total %d of %d note(s) successfully uploaded to Evernote" % (tmr.count_success, tmr.max), tmr.label, do_print=True) + # if number_updated > 0: log.go(" > %4d updated in Anki" % number_updated, tmr.label, do_print=True) + # if tmr.count_queued > 0: log.go(" > %4d queued for validation" % tmr.count_queued, tmr.label, do_print=True) + # if tmr.count_error > 0: log.go(" > %4d error(s) occurred" % tmr.count_error, tmr.label, do_print=True) ## HOCM/MVP \ No newline at end of file diff --git a/anknotes/error.py b/anknotes/error.py index 7dc86a3..ff89e0b 100644 --- a/anknotes/error.py +++ b/anknotes/error.py @@ -1,14 +1,7 @@ import errno from anknotes.evernote.edam.error.ttypes import EDAMErrorCode from anknotes.logging import log_error, log, showInfo, show_tooltip - - -class RateLimitErrorHandling: - IgnoreError, ToolTipError, AlertError = range(3) - - -EDAM_RATE_LIMIT_ERROR_HANDLING = RateLimitErrorHandling.ToolTipError -DEBUG_RAISE_API_ERRORS = False +from anknotes.constants import * latestSocketError = {'code': 0, 'friendly_error_msg': '', 'constant': ''} @@ -34,9 +27,9 @@ def HandleSocketError(e, strErrorBase): log_error(" SocketError.%s: " % error_constant + strError) log_error(str(e)) log(" SocketError.%s: " % error_constant + strError, 'api') - if EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.AlertError: + if EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.AlertError: showInfo(strError) - elif EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.ToolTipError: + elif EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.ToolTipError: show_tooltip(strError) return True @@ -46,8 +39,7 @@ def HandleSocketError(e, strErrorBase): def HandleEDAMRateLimitError(e, strError): global latestEDAMRateLimit - if not e.errorCode is EDAMErrorCode.RATE_LIMIT_REACHED: - return False + if not e.errorCode is EDAMErrorCode.RATE_LIMIT_REACHED: return False latestEDAMRateLimit = e.rateLimitDuration m, s = divmod(e.rateLimitDuration, 60) strError = "Error: Rate limit has been reached while %s\r\n" % strError @@ -55,8 +47,8 @@ def HandleEDAMRateLimitError(e, strError): log_strError = " EDAMErrorCode.RATE_LIMIT_REACHED: " + strError.replace('\r\n', '\n') log_error(log_strError) log(log_strError, 'api') - if EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.AlertError: + if EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.AlertError: showInfo(strError) - elif EDAM_RATE_LIMIT_ERROR_HANDLING is RateLimitErrorHandling.ToolTipError: + elif EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.ToolTipError: show_tooltip(strError) return True diff --git a/anknotes/logging.py b/anknotes/logging.py index 8e91078..70e8f73 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -32,13 +32,12 @@ def print_safe(strr, prefix=''): print str_safe(strr, prefix) -def show_tooltip(text, time_out=7000, delay=None): +def show_tooltip(text, time_out=7000, delay=None, do_log=False): + if do_log: log(text) if delay: - try: - return mw.progress.timer(delay, lambda: tooltip(text, time_out), False) - except: - pass - tooltip(text, time_out) + try: return mw.progress.timer(delay, lambda: tooltip(text, time_out), False) + except: pass + tooltip(text, time_out) def counts_as_str(count, max=None): from anknotes.counters import Counter @@ -48,7 +47,7 @@ def counts_as_str(count, max=None): if count == max: return "All %s" % str(count).center(3) return "Total %s of %s" % (str(count).center(3), str(max).center(3)) -def show_report(title, header=None, log_lines=None, delay=None, log_header_prefix = ' '*5, filename=None, blank_line_before=True): +def show_report(title, header=None, log_lines=None, delay=None, log_header_prefix = ' '*5, filename=None, blank_line_before=True, hr_if_empty=False): if log_lines is None: log_lines = [] if header is None: header = [] lines = [] @@ -65,10 +64,10 @@ def show_report(title, header=None, log_lines=None, delay=None, log_header_prefi if blank_line_before: log_blank(filename=filename) log(title, filename=filename) if len(lines) == 1 and not lines[0]: - log(" " + "-" * 187, timestamp=False, filename=filename) - else: - log(" " + "-" * 187 + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True, filename=filename) - log_blank(filename=filename) + if hr_if_empty: log(" " + "-" * 185, timestamp=False, filename=filename) + return + log(" " + "-" * 185 + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True, filename=filename) + log_blank(filename=filename) def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0, cancelButton=False, richText=False, minHeight=None, minWidth=400, styleSheet=None, convertNewLines=True): @@ -199,7 +198,7 @@ def __init__(self, base_path=None, default_filename=None, rm_path=False): else: self.caller_info = caller_name() if self.caller_info: - self.base_path = create_log_filename(c.Base) + self.base_path = create_log_filename(self.caller_info.Base) if rm_path: rm_log_path(self.base_path) @@ -324,8 +323,8 @@ def log(content=None, filename=None, prefix='', clear=False, timestamp=True, ext def log_sql(content, **kwargs): log(content, 'sql', **kwargs) -def log_error(content, crossPost=True, **kwargs): - log(content, ('+' if crossPost else '') + 'error', **kwargs) +def log_error(content, crosspost_to_default=True, **kwargs): + log(content, ('+' if crosspost_to_default else '') + 'error', **kwargs) def print_dump(obj): diff --git a/anknotes/menu.py b/anknotes/menu.py index ba48df7..441c4ed 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -21,7 +21,6 @@ from aqt.utils import getText # from anki.storage import Collection -DEBUG_RAISE_API_ERRORS = False # log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') @@ -226,6 +225,7 @@ def validate_pending_notes(showAlerts=True, uploadAfterValidation=True, callback info = ("ERROR: {%s}<HR>" % stderrdata) if stderrdata else '' allowUpload = True if showAlerts: + log('vpn stdout: ' + FILES.SCRIPTS.VALIDATION + '\n' + stdoutdata) tds = [[str(count), '<a href="%s">VIEW %s VALIDATIONS LOG</a>' % (fn, key.upper())] for key, fn, count in [ [key, get_log_full_path('MakeNoteQueue\\' + key, as_url_link=True), int(re.search(r'CHECKING +(\d{1,3}) +' + key.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1))] for key in ['Pending', 'Successful', 'Failed']] if count > 0] @@ -269,48 +269,48 @@ def see_also(steps=None, showAlerts=None, validationComplete=False): remaining_steps=steps if 1 in steps: # Should be unnecessary once See Also algorithms are finalized - log(" > See Also: Step 1: Processing Un Added See Also Notes") + log(" > See Also: Step 1: Process Un Added See Also Notes") controller.process_unadded_see_also_notes() if 2 in steps: - log(" > See Also: Step 2: Extracting Links from TOC") + log(" > See Also: Step 2: Extract Links from TOC") controller.anki.extract_links_from_toc() if 3 in steps: - log(" > See Also: Step 3: Creating Auto TOC Evernote Notes") + log(" > See Also: Step 3: Create Auto TOC Evernote Notes") controller.create_auto_toc() if 4 in steps: if validationComplete: - log(" > See Also: Step 4: Validate and Upload Auto TOC Notes: Upload Validating Notes") + log(" > See Also: Step 4A: Validate and Upload Auto TOC Notes: Upload Validated Notes") upload_validated_notes(multipleSteps) - else: - steps = [-4] + validationComplete = False + else: steps = [-4] if 5 in steps: - log(" > See Also: Step 5: Rebuild TOC/Outline Link Database") + log(" > See Also: Step 5: Rebuild TOC/Outline Link Database") controller.anki.extract_links_from_toc() if 6 in steps: - log(" > See Also: Step 6: Inserting TOC/Outline Links Into Anki Notes' See Also Field") + log(" > See Also: Step 6: Insert TOC/Outline Links Into Anki Notes' See Also Field") controller.anki.insert_toc_into_see_also() if 7 in steps: - log(" > See Also: Step 7: Update See Also Footer In Evernote Notes") + log(" > See Also: Step 7: Update See Also Footer In Evernote Notes") from anknotes import detect_see_also_changes detect_see_also_changes.main() if 8 in steps: if validationComplete: - log(" > See Also: Step 8: Validate and Upload Modified Notes: Upload Validating Notes") + log(" > See Also: Step 8A: Validate and Upload Modified Evernote Notes: Upload Validated Notes") upload_validated_notes(multipleSteps) - else: - steps = [-8] + else: steps = [-8] if 9 in steps: - log(" > See Also: Step 9: Inserting TOC/Outline Contents Into Anki Notes") + log(" > See Also: Step 9: Insert TOC/Outline Contents Into Anki Notes") controller.anki.insert_toc_and_outline_contents_into_notes() do_validation = steps[0]*-1 if do_validation>0: - log(" > See Also: Step %d: Validate and Upload %s Notes: Validating Notes" % (do_validation, {4: 'Auto TOC', 8: 'Modified Evernote'}[do_validation])) - remaining_steps = remaining_steps[remaining_steps.index(do_validation)+validationComplete and 1 or 0:] + log(" > See Also: Step %dB: Validate and Upload %s Notes: Validate Notes" % (do_validation, {4: 'Auto TOC', 8: 'Modified Evernote'}[do_validation])) + remaining_steps = remaining_steps[remaining_steps.index(do_validation):] validate_pending_notes(showAlerts, callback=lambda: see_also(remaining_steps, False, True)) def update_ancillary_data(): controller = anknotes.Controller.Controller() + log("Ancillary data - loaded controller - " + str(controller.evernote) + " - " + str(controller.evernote.client), 'client') controller.update_ancillary_data() diff --git a/anknotes/shared.py b/anknotes/shared.py index 1b2f8a5..5f1a3e6 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -6,12 +6,10 @@ from sqlite3 import dbapi2 as sqlite import os import re +import sys ### Check if in Anki -try: - from aqt import mw - inAnki = True -except: inAnki = False +inAnki='anki' in sys.modules ### Anknotes Imports from anknotes.constants import * @@ -21,13 +19,12 @@ from anknotes.db import * ### Anki and Evernote Imports -try: +if inAnki: + from aqt import mw from aqt.qt import QIcon, QPixmap, QPushButton, QMessageBox from aqt.utils import tooltip from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ EDAMNotFoundException -except: - pass # log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') def get_friendly_interval_string(lastImport): diff --git a/anknotes/stopwatch/__init__.py b/anknotes/stopwatch/__init__.py index 3950d80..64a048b 100644 --- a/anknotes/stopwatch/__init__.py +++ b/anknotes/stopwatch/__init__.py @@ -8,7 +8,7 @@ import time from anknotes.structs import EvernoteAPIStatus -from anknotes.logging import caller_name, log, log_banner, show_report, counts_as_str, showInfo +from anknotes.logging import caller_name, log, log_banner, log_blank, show_report, counts_as_str, showInfo from anknotes.counters import Counter, EvernoteCounter """stopwatch is a very simple Python module for measuring time. @@ -211,7 +211,8 @@ class Timer(object): __stopped = None __start = None __status = EvernoteAPIStatus.Uninitialized - __counts = EvernoteCounter() + __counts = None + __did_break = True __laps = 0 __interval = 100 __parent_timer = None @@ -220,11 +221,14 @@ class Timer(object): @property def counts(self): + if self.__counts is None: + log("Init counter from property: " + repr(self.__counts), "counters") + self.__counts = EvernoteCounter() return self.__counts @counts.setter def counts(self, value): - self.__counts__ = value + self.__counts = value @property def laps(self): @@ -363,7 +367,7 @@ def autoStep(self, returned_tuple, title=None, update=None, val=None): return self.extractStatus(returned_tuple, update) def extractStatus(self, returned_tuple, update=None): - self.report_result = self.reportStatus(returned_tuple[0], None) + self.report_result = self.reportStatus(returned_tuple[0], update) if len(returned_tuple) == 2: return returned_tuple[1] return returned_tuple[1:] @@ -406,7 +410,7 @@ def reportQueued(self, save_status=True, update=None): @property def ReportHeader(self): - return self.info.FormatLine("%s {r} were processed" % counts_as_str(self.counts.total, self.counts.max), self.count) + return None if not self.counts.total else self.info.FormatLine("%s {r} were processed" % counts_as_str(self.counts.total, self.counts.max), self.counts.total) def ReportSingle(self, text, count, subtext='', queued_text='', queued=0, subcount=0, process_subcounts=True): if not count: return [] @@ -429,7 +433,11 @@ def Report(self, subcount_created=0, subcount_updated=0): str_tips += self.ReportSingle('already exist but were unchanged', self.counts.skipped, process_subcounts=False) if self.counts.error: str_tips.append("%d Error(s) occurred " % self.counts.error.val) if self.status == EvernoteAPIStatus.ExceededLocalLimit: str_tips.append("Action was prematurely terminated because locally-defined limit of %d was exceeded." % self.counts.max_allowed.val) - show_report(" > %s Complete" % self.info.Action, self.ReportHeader, str_tips, blank_line_before=False) + report_title = " > %s Complete" % self.info.Action + if self.counts.total is 0: report_title += self.info.FormatLine(": No {r} were processed") + show_report(report_title, self.ReportHeader, str_tips, blank_line_before=False) + log_blank(filename='counters') + log(self.counts.fullSummary((self.label if self.label else 'Counter') + ': End'), 'counters') def step(self, title=None, val=None): if val is None and (isinstance(title, str) or isinstance(title, unicode)) and title.isdigit(): @@ -447,19 +455,28 @@ def info(self): """ return self.__info + @property + def did_break(self): return self.__did_break + + def reportNoBreak(self): self.__did_break = False + + @property + def should_retry(self): return self.did_break and self.status != EvernoteAPIStatus.ExceededLocalLimit + @property def automated(self): if not self.info: return False return self.info.Automated def hasActionInfo(self): - return self.info and self.counts.max + return self.info is not None and self.counts.max def __init__(self, max=None, interval=100, info=None, infoStr=None, automated=None, enabled=None, begin=True, label=None, display_initial_info=None, max_allowed=None): """ :type info : ActionInfo """ simple_label = False + self.counts = EvernoteCounter() self.counts.max_allowed = -1 if max_allowed is None else max_allowed self.__interval = interval if type(info) == str or type(info) == unicode: info = ActionInfo(info) @@ -471,13 +488,16 @@ def __init__(self, max=None, interval=100, info=None, infoStr=None, automated=No elif label: info.__label = label if max is not None and info and (info.Max is None or info.Max <= 0): info.Max = max self.counts.max = -1 if max is None else max + self.__did_break = True self.__info = info self.__action_initialized = False - self.__action_attempted = self.hasActionInfo and display_initial_info is not False + self.__action_attempted = self.hasActionInfo and (display_initial_info is not False) if self.__action_attempted: - self.__action_initialized = info.displayInitialInfo(max=self.counts.max,interval=interval, automated=automated, enabled=enabled) is EvernoteAPIStatus.Initialized - if begin: - self.reset() + if self.info is None: print "Unexpected; Timer has no ActionInfo instance" + else: self.__action_initialized = self.info.displayInitialInfo(max=self.counts.max,interval=interval, automated=automated, enabled=enabled) is EvernoteAPIStatus.Initialized + if begin: self.reset(False) + log_blank(filename='counters') + log(self.counts.fullSummary((self.label if self.label else 'Counter') + ': Start'), 'counters') @property def willReportProgress(self): @@ -494,8 +514,18 @@ def interval(self): def start(self): self.reset() - def reset(self): - self.counts = EvernoteCounter() + def reset(self, reset_counter = True): + # keep = [] + # if self.counts: + # keep = [self.counts.max, self.counts.max_allowed] + # del self.__counts + if reset_counter: + log("Resetting counter", 'counters') + if self.counts is None: self.counts = EvernoteCounter() + else: self.counts.reset() + # if keep: + # self.counts.max = keep[0] + # self.counts.max_allowed = keep[1] if not self.__stopped: self.stop() self.__stopped = None self.__start = self.__time() diff --git a/anknotes/structs.py b/anknotes/structs.py index c4abb00..6fcded8 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -171,8 +171,7 @@ def Title(self): return self.__title__ @property - def FullTitle(self): - return self.Title.FullTitle + def FullTitle(self): return self.Title.FullTitle @Title.setter def Title(self, value): @@ -309,6 +308,8 @@ class EvernoteAPIStatus(AutoNumberedEnum): """:type : EvernoteAPIStatus""" NotFoundError = () """:type : EvernoteAPIStatus""" + MissingDataError = () + """:type : EvernoteAPIStatus""" UnhandledError = () """:type : EvernoteAPIStatus""" GenericError = () diff --git a/anknotes/toc.py b/anknotes/toc.py index 1c23a23..07dc9ce 100644 --- a/anknotes/toc.py +++ b/anknotes/toc.py @@ -81,6 +81,9 @@ def sortIfNeeded(self): if self.__isSorted__: return self.sortChildren() + @property + def FullTitle(self): return self.Title.FullTitle if self.Title else "" + @property def Level(self): return self.Title.Level @@ -130,12 +133,12 @@ def addHierarchy(self, tocHierarchy): tocNewTitle = tocHierarchy.Title tocNewLevel = tocNewTitle.Level selfLevel = self.Title.Level - tocTestBase = tocHierarchy.Title.FullTitle.replace(self.Title.FullTitle, '') + tocTestBase = tocHierarchy.FullTitle.replace(self.FullTitle, '') if tocTestBase[:2] == ': ': tocTestBase = tocTestBase[2:] print " \nAdd Hierarchy: %-70s --> %-40s\n-------------------------------------" % ( - self.Title.FullTitle, tocTestBase) + self.FullTitle, tocTestBase) if selfLevel > tocHierarchy.Title.Level: print "New Title Level is Below current level" @@ -171,33 +174,33 @@ def addHierarchy(self, tocHierarchy): assert (isinstance(tocChild, TOCHierarchyClass)) if tocChild.Title.TOCName == tocNewSelfChildTOCName: print "%-60s Child %-20s Match Succeeded for %s." % ( - self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + self.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) success = tocChild.addHierarchy(tocHierarchy) if success: return True print "%-60s Child %-20s Match Succeeded for %s: However, unable to add to matched child" % ( - self.Title.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + self.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) print "%-60s Child %-20s Search failed for %s" % ( - self.Title.FullTitle + ':', tocNewSelfChild.Name, tocNewChildNamesTitle) + self.FullTitle + ':', tocNewSelfChild.Name, tocNewChildNamesTitle) newChild = tocHierarchy if isDirectChild else TOCHierarchyClass(tocNewSelfChild) newChild.parent = self if isDirectChild: print "%-60s Child %-20s Created Direct Child for %s." % ( - self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + self.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) success = True else: print "%-60s Child %-20s Created Title-Only Child for %-40ss." % ( - self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + self.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) success = newChild.addHierarchy(tocHierarchy) print "%-60s Child %-20s Created Title-Only Child for %-40s: Match %s." % ( - self.Title.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle, + self.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle, "succeeded" if success else "failed") self.__isSorted__ = False self.Children.append(newChild) print "%-60s Child %-20s Appended Child for %s. Operation was an overall %s." % ( - self.Title.FullTitle + ':', newChild.Title.Name + ':', tocNewChildNamesTitle, + self.FullTitle + ':', newChild.Title.Name + ':', tocNewChildNamesTitle, "success" if success else "failure") return success @@ -206,7 +209,7 @@ def sortChildren(self): self.__isSorted__ = True def __strsingle__(self, fullTitle=False): - selfTitleStr = self.Title.FullTitle + selfTitleStr = self.FullTitle selfNameStr = self.Title.Name selfLevel = self.Title.Level selfDepth = self.Title.Depth @@ -284,7 +287,7 @@ def GetOrderedList(self, title=None): return base def __reprsingle__(self, fullTitle=True): - selfTitleStr = self.Title.FullTitle + selfTitleStr = self.FullTitle selfNameStr = self.Title.Name # selfLevel = self.title.Level # selfDepth = self.title.Depth diff --git a/anknotes_remove_tags.py b/anknotes_remove_tags.py index 07b9615..510bec8 100644 --- a/anknotes_remove_tags.py +++ b/anknotes_remove_tags.py @@ -1,26 +1,19 @@ # -*- coding: utf-8 -*- +import sys +inAnki='anki' in sys.modules -try: - from pysqlite2 import dbapi2 as sqlite -except ImportError: - from sqlite3 import dbapi2 as sqlite +if not inAnki: + from anknotes.shared import * + try: from pysqlite2 import dbapi2 as sqlite + except ImportError: from sqlite3 import dbapi2 as sqlite -try: - from aqt.utils import getText - isAnki = True -except: - isAnki = False + Error = sqlite.Error + ankDBSetLocal() -if not isAnki: - from anknotes.shared import * + tags = ',#Imported,#Anki_Import,#Anki_Import_High_Priority,' + # ankDB().setrowfactory() + dbRows = ankDB().all("SELECT * FROM %s WHERE ? LIKE '%%,' || name || ',%%' " % TABLES.EVERNOTE.TAGS, tags) - Error = sqlite.Error - ankDBSetLocal() - - tags = ',#Imported,#Anki_Import,#Anki_Import_High_Priority,' - # ankDB().setrowfactory() - dbRows = ankDB().all("SELECT * FROM %s WHERE ? LIKE '%%,' || name || ',%%' " % TABLES.EVERNOTE.TAGS, tags) - - for dbRow in dbRows: - ankDB().execute("UPDATE %s SET tagNames = REPLACE(tagNames, ',%s,', ','), tagGuids = REPLACE(tagGuids, ',%s,', ',') WHERE tagGuids LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, dbRow['name'], dbRow['guid'],dbRow['guid'] )) - ankDB().commit() + for dbRow in dbRows: + ankDB().execute("UPDATE %s SET tagNames = REPLACE(tagNames, ',%s,', ','), tagGuids = REPLACE(tagGuids, ',%s,', ',') WHERE tagGuids LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, dbRow['name'], dbRow['guid'],dbRow['guid'] )) + ankDB().commit() diff --git a/anknotes_start_detect_see_also_changes.py b/anknotes_start_detect_see_also_changes.py index 32c8c46..ad5e23e 100644 --- a/anknotes_start_detect_see_also_changes.py +++ b/anknotes_start_detect_see_also_changes.py @@ -1,9 +1,6 @@ -try: - from aqt.utils import getText - isAnki = True -except: - isAnki = False +import sys +inAnki='anki' in sys.modules -if not isAnki: +if not inAnki: from anknotes import detect_see_also_changes detect_see_also_changes.main() \ No newline at end of file diff --git a/anknotes_start_find_deleted_notes.py b/anknotes_start_find_deleted_notes.py index 347c2bf..2adef2f 100644 --- a/anknotes_start_find_deleted_notes.py +++ b/anknotes_start_find_deleted_notes.py @@ -1,10 +1,7 @@ -try: - from aqt.utils import getText - isAnki = True -except: - isAnki = False +import sys +inAnki='anki' in sys.modules -if not isAnki: +if not inAnki: from anknotes import find_deleted_notes from anknotes.db import ankDBSetLocal ankDBSetLocal() diff --git a/anknotes_start_note_validation.py b/anknotes_start_note_validation.py index 2a6ebaa..22b10b8 100644 --- a/anknotes_start_note_validation.py +++ b/anknotes_start_note_validation.py @@ -44,9 +44,9 @@ db = ankDB() db.Init() - failed_queued_items = db.all("SELECT * FROM %s WHERE validation_status = -1 " % TABLES.MAKE_NOTE_QUEUE) - pending_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 0" % TABLES.MAKE_NOTE_QUEUE) - success_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.MAKE_NOTE_QUEUE) + failed_queued_items = db.all("SELECT * FROM %s WHERE validation_status = -1 " % TABLES.NOTE_VALIDATION_QUEUE) + pending_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 0" % TABLES.NOTE_VALIDATION_QUEUE) + success_queued_items = db.all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.NOTE_VALIDATION_QUEUE) currentLog = 'Successful' log("------------------------------------------------", 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True, clear=True) @@ -104,7 +104,7 @@ errors = '\n'.join(errors) - sql = "UPDATE %s SET validation_status = %d, validation_result = '%s' WHERE " % (TABLES.MAKE_NOTE_QUEUE, validation_status, escape_text_sql(errors)) + sql = "UPDATE %s SET validation_status = %d, validation_result = '%s' WHERE " % (TABLES.NOTE_VALIDATION_QUEUE, validation_status, escape_text_sql(errors)) if guid: sql += "guid = '%s'" % guid else: From caf04921e4396ffe1b50aa9b327ed63d6ef42496 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Mon, 28 Sep 2015 11:01:44 -0400 Subject: [PATCH 29/70] Bug fix, update tag deletion check --- anknotes/ankEvernote.py | 2 +- anknotes/settings.py | 2 +- anknotes/shared.py | 21 ++++++++++++--------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 75554e1..e125db4 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -351,7 +351,7 @@ def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): def check_ancillary_data_up_to_date(self): new_tags = 0 if self.check_tags_up_to_date() else self.update_tags_database("Tags were not up to date when checking ancillary data") - new_nbs = 0 f self.check_notebooks_up_to_date() else self.update_notebooks_database() + new_nbs = 0 if self.check_notebooks_up_to_date() else self.update_notebooks_database() self.report_ancillary_data_results(new_tags, new_nbs) def update_ancillary_data(self): diff --git a/anknotes/settings.py b/anknotes/settings.py index e76ef06..4e02ecd 100644 --- a/anknotes/settings.py +++ b/anknotes/settings.py @@ -363,7 +363,7 @@ def setup_evernote(self): # Delete Tags To Import delete_evernote_query_tags = QCheckBox(" Also Delete Search Tags", self) - delete_evernote_query_tags.setChecked(mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, True)) + delete_evernote_query_tags.setChecked(mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False)) delete_evernote_query_tags.stateChanged.connect(update_delete_evernote_query_tags) # Add Form Row for Evernote Tag Options diff --git a/anknotes/shared.py b/anknotes/shared.py index 5f1a3e6..61c2dc7 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -59,17 +59,20 @@ class UpdateExistingNotes: class EvernoteQueryLocationType: RelativeDay, RelativeWeek, RelativeMonth, RelativeYear, AbsoluteDate, AbsoluteDateTime = range(6) - -def get_tag_names_to_import(tagNames, evernoteTags=None, evernoteTagsToDelete=None, keepEvernoteQueryTags=True): - if keepEvernoteQueryTags is None: keepEvernoteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, True) - if not keepEvernoteQueryTags: return {} if isinstance(tagNames, dict) else [] - if evernoteTags is None: evernoteTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() +@staticmethod +def __check_tag_name__(v, tags_to_delete): + return v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete) and (not hasattr(v, 'name') or getattr(v, 'name') not in tags_to_delete) + +def get_tag_names_to_import(tagNames, evernoteQueryTags=None, evernoteTagsToDelete=None, keepEvernoteTags=None, deleteEvernoteQueryTags=None): + if keepEvernoteTags is None: keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, KEEP_TAGS_DEFAULT_VALUE) + if not keepEvernoteTags: return {} if isinstance(tagNames, dict) else [] + if evernoteQueryTags is None: evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() + if deleteEvernoteQueryTags is None: deleteEvernoteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False) if evernoteTagsToDelete is None: evernoteTagsToDelete = mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "").replace(',', ' ').split() - tags_to_delete = evernoteTags + evernoteTagsToDelete + tags_to_delete = evernoteQueryTags if deleteEvernoteQueryTags else [] + evernoteTagsToDelete if isinstance(tagNames, dict): - return {k: v for k, v in tagNames.items() if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)} - return sorted([v for v in tagNames if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)], - key=lambda s: s.lower()) + return {k: v for k, v in tagNames.items() if __check_tag_name__(v, tags_to_delete)} + return sorted([v for v in tagNames if __check_tag_name__(v, tags_to_delete)]) def find_evernote_guids(content): return [x.group('guid') for x in re.finditer(r'\b(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b', content)] From 980bf70b1cc9b89a6f853257e46d7f75025197df Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Mon, 28 Sep 2015 11:15:07 -0400 Subject: [PATCH 30/70] Bug fix, update tag deletion check --- anknotes/ankEvernote.py | 16 +++++++++------- anknotes/settings.py | 2 +- anknotes/shared.py | 20 +++++++++++--------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 75554e1..7327407 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -341,7 +341,7 @@ def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): if inAnki: fetcher.evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() fetcher.keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE) - fetcher.deleteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, True) + fetcher.deleteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False) fetcher.tagsToDelete = mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "").replace(',', ' ').split() for evernote_guid in self.evernote_guids: self.evernote_guid = evernote_guid @@ -351,21 +351,23 @@ def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): def check_ancillary_data_up_to_date(self): new_tags = 0 if self.check_tags_up_to_date() else self.update_tags_database("Tags were not up to date when checking ancillary data") - new_nbs = 0 f self.check_notebooks_up_to_date() else self.update_notebooks_database() - self.report_ancillary_data_results(new_tags, new_nbs) + new_nbs = 0 if self.check_notebooks_up_to_date() else self.update_notebooks_database() + self.report_ancillary_data_results(new_tags, new_nbs, 'Forced ') def update_ancillary_data(self): new_tags = self.update_tags_database("Manual call to update ancillary data") new_nbs = self.update_notebooks_database() - self.report_ancillary_data_results(new_tags, new_nbs) + self.report_ancillary_data_results(new_tags, new_nbs, 'Manual ', report_blank=True) @staticmethod - def report_ancillary_data_results(new_tags, new_nbs): - if new_tags is 0 and new_nbs is 0: strr = 'No new tags or notebooks found' + def report_ancillary_data_results(new_tags, new_nbs, title_prefix='', report_blank=False): + if new_tags is 0 and new_nbs is 0: + if not report_blank: return + strr = 'No new tags or notebooks found' elif new_tags is 0: strr = '%d new notebook%s found' % (new_nbs, '' if new_nbs is 1 else 's') elif new_nbs is 0: strr = '%d new tag%s found' % (new_tags, '' if new_tags is 1 else 's') else: strr = '%d new tag%s and %d new notebook%s found' % (new_tags, '' if new_tags is 1 else 's', new_nbs, '' if new_nbs is 1 else 's') - show_tooltip("Update of ancillary data complete: " + strr, do_log=True) + show_tooltip("%sUpdate of ancillary data complete: " % title_prefix + strr, do_log=True) def set_notebook_data(self): if not hasattr(self, 'notebook_data') or not self.notebook_data or len(self.notebook_data.keys()) == 0: diff --git a/anknotes/settings.py b/anknotes/settings.py index e76ef06..4e02ecd 100644 --- a/anknotes/settings.py +++ b/anknotes/settings.py @@ -363,7 +363,7 @@ def setup_evernote(self): # Delete Tags To Import delete_evernote_query_tags = QCheckBox(" Also Delete Search Tags", self) - delete_evernote_query_tags.setChecked(mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, True)) + delete_evernote_query_tags.setChecked(mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False)) delete_evernote_query_tags.stateChanged.connect(update_delete_evernote_query_tags) # Add Form Row for Evernote Tag Options diff --git a/anknotes/shared.py b/anknotes/shared.py index 5f1a3e6..4074067 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -59,17 +59,19 @@ class UpdateExistingNotes: class EvernoteQueryLocationType: RelativeDay, RelativeWeek, RelativeMonth, RelativeYear, AbsoluteDate, AbsoluteDateTime = range(6) - -def get_tag_names_to_import(tagNames, evernoteTags=None, evernoteTagsToDelete=None, keepEvernoteQueryTags=True): - if keepEvernoteQueryTags is None: keepEvernoteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, True) - if not keepEvernoteQueryTags: return {} if isinstance(tagNames, dict) else [] - if evernoteTags is None: evernoteTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() +def __check_tag_name__(v, tags_to_delete): + return v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete) and (not hasattr(v, 'name') or getattr(v, 'name') not in tags_to_delete) + +def get_tag_names_to_import(tagNames, evernoteQueryTags=None, evernoteTagsToDelete=None, keepEvernoteTags=None, deleteEvernoteQueryTags=None): + if keepEvernoteTags is None: keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE) + if not keepEvernoteTags: return {} if isinstance(tagNames, dict) else [] + if evernoteQueryTags is None: evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() + if deleteEvernoteQueryTags is None: deleteEvernoteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False) if evernoteTagsToDelete is None: evernoteTagsToDelete = mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "").replace(',', ' ').split() - tags_to_delete = evernoteTags + evernoteTagsToDelete + tags_to_delete = evernoteQueryTags if deleteEvernoteQueryTags else [] + evernoteTagsToDelete if isinstance(tagNames, dict): - return {k: v for k, v in tagNames.items() if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)} - return sorted([v for v in tagNames if v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete)], - key=lambda s: s.lower()) + return {k: v for k, v in tagNames.items() if __check_tag_name__(v, tags_to_delete)} + return sorted([v for v in tagNames if __check_tag_name__(v, tags_to_delete)]) def find_evernote_guids(content): return [x.group('guid') for x in re.finditer(r'\b(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b', content)] From e86a43f3c8d93a79a693923ea3c9e508dc159654 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Mon, 28 Sep 2015 11:36:24 -0400 Subject: [PATCH 31/70] Fix unicode error with title check --- anknotes/AnkiNotePrototype.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 0542ccd..7e09104 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -429,10 +429,16 @@ def update_note(self): self.OriginalGuid = get_evernote_guid_from_anki_fields(flds) db_title = ankDB().scalar( "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.OriginalGuid)) - do_log_title=False new_guid = self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') new_title = self.Fields[FIELDS.TITLE] - old_title = db_title + self.check_titles_equal(db_title, new_title, new_guid) + self.note.flush() + self.update_note_model() + self.Counts.Updated += 1 + return True + + + def check_titles_equal(self, old_title, new_title, new_guid, log_title='DB INFO UNEQUAL'): if not isinstance(new_title, unicode): try: new_title = unicode(new_title, 'utf-8') except: do_log_title = True @@ -440,14 +446,12 @@ def update_note(self): try: old_title = unicode(old_title, 'utf-8') except: do_log_title = True if do_log_title or new_title != old_title or new_guid != self.OriginalGuid: - log_str = ' %s: DB INFO UNEQUAL: ' % (self.OriginalGuid + ('' if new_guid == self.OriginalGuid else ' vs %s' % new_guid)) + ' ' + new_title + ' vs ' + old_title + log_str = ' %s: %s: ' % (log_title, self.OriginalGuid + ('' if new_guid == self.OriginalGuid else ' vs %s' % new_guid)) + ' ' + new_title + ' vs ' + old_title log_error(log_str) self.log_update(log_str) - self.note.flush() - self.update_note_model() - self.Counts.Updated += 1 - return True - + return False + return True + @property def Title(self): """:rtype : EvernoteNoteTitle.EvernoteNoteTitle """ @@ -462,7 +466,7 @@ def Title(self): def FullTitle(self): return self.Title.FullTitle - def add_note(self): + def add_note(self): self.create_note() if self.note is not None: collection = self.Anki.collection() @@ -470,9 +474,7 @@ def add_note(self): TABLES.EVERNOTE.NOTES, self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, ''))) log(' %s: ADD: ' % self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') + ' ' + self.Fields[FIELDS.TITLE], 'AddUpdateNote') - if self.Fields[FIELDS.TITLE] != db_title: - log(' %s: DB TITLE: ' % re.sub(r'.', ' ', self.Fields[FIELDS.EVERNOTE_GUID].replace( - FIELDS.EVERNOTE_GUID_PREFIX, '')) + ' ' + db_title, 'AddUpdateNote') + self.check_titles_equal(db_title, self.Fields[FIELDS.TITLE], self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, 'NEW NOTE TITLE ')) try: collection.addNote(self.note) except: From 30225f3a9d048600086561e35820fc55b31fd580 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Mon, 28 Sep 2015 12:14:39 -0400 Subject: [PATCH 32/70] Update last commit; fix local line endings --- anknotes/AnkiNotePrototype.py | 1 + anknotes/extra/ancillary/_attributes.css | 1 - anknotes/extra/ancillary/index.html | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 7e09104..0e45ac4 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -439,6 +439,7 @@ def update_note(self): def check_titles_equal(self, old_title, new_title, new_guid, log_title='DB INFO UNEQUAL'): + do_log_title = False if not isinstance(new_title, unicode): try: new_title = unicode(new_title, 'utf-8') except: do_log_title = True diff --git a/anknotes/extra/ancillary/_attributes.css b/anknotes/extra/ancillary/_attributes.css index 8a8a018..bae4839 100644 --- a/anknotes/extra/ancillary/_attributes.css +++ b/anknotes/extra/ancillary/_attributes.css @@ -1,4 +1,3 @@ - /******************************************************************************************************* Helpful Attributes *******************************************************************************************************/ diff --git a/anknotes/extra/ancillary/index.html b/anknotes/extra/ancillary/index.html index c4478fb..113b245 100644 --- a/anknotes/extra/ancillary/index.html +++ b/anknotes/extra/ancillary/index.html @@ -1,4 +1,3 @@ - <html lang="en"> <head> <meta charset="utf-8"> From c1068b6468cb18b6de8c6d8fae310d7235af4163 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Mon, 28 Sep 2015 12:28:33 -0400 Subject: [PATCH 33/70] Update title check / counters inconsistency --- anknotes/Anki.py | 2 +- anknotes/AnkiNotePrototype.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/anknotes/Anki.py b/anknotes/Anki.py index 7b2d458..9df6479 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -116,7 +116,7 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang continue anki_note_prototype = AnkiNotePrototype(self, anki_field_info, ankiNote.TagNames, baseNote, notebookGuid=ankiNote.NotebookGuid, count=tmr.count, - count_update=tmr.counts.success, max_count=tmr.counts.max) + count_update=tmr.counts.success, max_count=tmr.counts.max.val) anki_note_prototype._log_update_if_unchanged_ = log_update_if_unchanged if (update and anki_note_prototype.update_note()) or (not update and anki_note_prototype.add_note() != -1): tmr.reportSuccess() return tmr.counts.success diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 0e45ac4..6b7dd4f 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -446,8 +446,9 @@ def check_titles_equal(self, old_title, new_title, new_guid, log_title='DB INFO if not isinstance(old_title, unicode): try: old_title = unicode(old_title, 'utf-8') except: do_log_title = True + guid_text = '' if self.OriginalGuid is None else ' ' + self.OriginalGuid + ('' if new_guid == self.OriginalGuid else ' vs %s' % new_guid) + ':' if do_log_title or new_title != old_title or new_guid != self.OriginalGuid: - log_str = ' %s: %s: ' % (log_title, self.OriginalGuid + ('' if new_guid == self.OriginalGuid else ' vs %s' % new_guid)) + ' ' + new_title + ' vs ' + old_title + log_str = ' %s: %s%s' % (log_title, guid_text, ' ' + new_title + ' vs ' + old_title) log_error(log_str) self.log_update(log_str) return False From 2dc4d48f9e3ae9d466694e0e8a21bf034844ae56 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Mon, 28 Sep 2015 12:50:45 -0400 Subject: [PATCH 34/70] Ensure TOC enex exists in find_deleted_notes --- anknotes/find_deleted_notes.py | 7 ++++++- anknotes/menu.py | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/anknotes/find_deleted_notes.py b/anknotes/find_deleted_notes.py index 34bc67e..763dc49 100644 --- a/anknotes/find_deleted_notes.py +++ b/anknotes/find_deleted_notes.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import os try: from pysqlite2 import dbapi2 as sqlite except ImportError: @@ -15,7 +16,11 @@ def do_find_deleted_notes(all_anki_notes=None): """ Error = sqlite.Error - + + if not os.path.isfile(FILES.USER.TABLE_OF_CONTENTS_ENEX): + log_error('Unable to proceed with find_deleted_notes: TOC enex does not exist.', do_print=True) + return False + enTableOfContents = file(FILES.USER.TABLE_OF_CONTENTS_ENEX, 'r').read() # find = file(os.path.join(PATH, "powergrep-find.txt") , 'r').read().splitlines() # replace = file(os.path.join(PATH, "powergrep-replace.txt") , 'r').read().replace('https://www.evernote.com/shard/s175/nl/19775535/' , '').splitlines() diff --git a/anknotes/menu.py b/anknotes/menu.py index 441c4ed..5376e1c 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -150,7 +150,7 @@ def upload_validated_notes(automated=False): def find_deleted_notes(automated=False): - if not automated and False: + if not automated: showInfo("""In order for this to work, you must create a 'Table of Contents' Note using the Evernote desktop application. Include all notes that you want to sync with Anki. Export this note to the following path: '%s'. @@ -171,6 +171,9 @@ def find_deleted_notes(automated=False): # stdoutdata = re.sub(' +', ' ', stdoutdata) from anknotes import find_deleted_notes returnedData = find_deleted_notes.do_find_deleted_notes() + if returnedData is False: + showInfo("An error occurred while executing the script. Please ensure you created the TOC note and saved it as instructed in the previous dialog.") + return lines = returnedData['Summary'] info = tableify_lines(lines, '#|Type|Info') # info = '<table><tr class=tr0><td class=t1>#</td><td class=t2>Type</td><td class=t3></td></tr>%s</table>' % '\n'.join(lines) From 0354cb548728e7235e23a1b5e0bfc1f4d3d43e63 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Tue, 29 Sep 2015 02:54:34 -0400 Subject: [PATCH 35/70] Created example user configuration file (constants_user.py) --- README.md | 171 ++++++------- anknotes/Anki.py | 52 ++-- anknotes/AnkiNotePrototype.py | 80 +++--- anknotes/Controller.py | 57 ++--- anknotes/EvernoteImporter.py | 64 ++--- anknotes/EvernoteNoteFetcher.py | 27 +- anknotes/EvernoteNotePrototype.py | 4 +- anknotes/EvernoteNoteTitle.py | 2 +- anknotes/EvernoteNotes.py | 9 +- anknotes/__main__.py | 67 +++-- anknotes/ankEvernote.py | 94 ++++--- anknotes/constants.py | 68 ++--- anknotes/constants_user.py | 9 + anknotes/counters.py | 240 +++++++++++++---- anknotes/db.py | 14 +- anknotes/detect_see_also_changes.py | 50 ++-- anknotes/error.py | 4 +- anknotes/extra/ancillary/FrontTemplate.htm | 17 +- anknotes/extra/ancillary/QMessageBox.css | 6 +- anknotes/extra/ancillary/_AviAnkiCSS.css | 79 +++--- anknotes/extra/ancillary/_attributes.css | 29 +-- anknotes/extra/ancillary/regex.txt | 4 +- anknotes/extra/ancillary/sorting.txt | 10 +- anknotes/extra/dev/anknotes_test.py | 21 +- anknotes/find_deleted_notes.py | 12 +- anknotes/html.py | 33 ++- anknotes/logging.py | 284 +++++++++++++++------ anknotes/menu.py | 30 +-- anknotes/settings.py | 29 +-- anknotes/shared.py | 27 +- anknotes/stopwatch/__init__.py | 130 +++++----- anknotes/structs.py | 97 +++---- anknotes/toc.py | 4 +- anknotes_remove_tags.py | 2 +- anknotes_start_note_validation.py | 35 ++- 35 files changed, 1045 insertions(+), 816 deletions(-) create mode 100644 anknotes/constants_user.py diff --git a/README.md b/README.md index 0b38067..5e66461 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Anknotes (Evernote to Anki Importer) **Forks and suggestions are very welcome.** -##Outline +##Outline 1. [Description] (#description) 1. [User Instructions] (#user-instructions) 1. [Current Features] (#current-features) @@ -24,12 +24,12 @@ An Anki plug-in for downloading Evernote notes to Anki directly from Anki. In ad - Optionally, you can customize [settings] (#settings) in the Anknotes tab in Anki's preferences 1. When you run it the first time a browser tab will open on the Evernote site asking you for access to your account - When you click okay you are taken to a website where the OAuth verification key is displayed. You paste that key into the open Anki prompt and click okay. - - Note that for the first 24 hours after granting access, you have unlimited API usage. After that, Evernote applies rate limiting. + - Note that for the first 24 hours after granting access, you have unlimited API usage. After that, Evernote applies rate limiting. - So, sync everything immediately! ## Current Features -#### Evernote Importing -- A rich set of [options] (#settings) will dynamically generate your query from tags, notebook, title, last updated date, or free text +#### Evernote Importing +- A rich set of [options] (#settings) will dynamically generate your query from tags, notebook, title, last updated date, or free text - Free text can include any valid [Evernote query] (https://dev.evernote.com/doc/articles/search_grammar.php) - [Auto Import] (#auto-import) is possible @@ -41,165 +41,155 @@ An Anki plug-in for downloading Evernote notes to Anki directly from Anki. In ad - [Automatically embed images] (#post-process-images) - [Occlude certain text] (#post-process-occlude) on fronts of Anki cards - [Generate Cloze Fields] (#post-process-cloze) - - [Process a "See Also" Footer field] (#see-also-footer-links) for showing links to other Evernote notes -- See the [Beta Functions] (#beta-functions) section below for info on See Also Footer fields, Table of Contents notes, and Outline notes - + - [Process a "See Also" Footer field] (#see-also-footer-links) for showing links to other Evernote notes +- See the [Beta Functions] (#beta-functions) section below for info on See Also Footer fields, Table of Contents notes, and Outline notes ## Settings #### Evernote Query -- You can enter any valid Evernote Query in the `Search Terms` field -- The check box before a given text field enables or disables that field -- Anknotes requires **all fields match** by default. - - You can use the `Match Any Terms` option to override this, but see the Evernote documentation on search for limitations - -### Pagination -- Controls the offset parameter of the Evernote search. -- Auto Pagination is recommended and on by default - +- You can enter any valid Evernote Query in the `Search Terms` field +- The check box before a given text field enables or disables that field +- Anknotes requires **all fields match** by default. + - You can use the `Match Any Terms` option to override this, but see the Evernote documentation on search for limitations +### Pagination +- Controls the offset parameter of the Evernote search. +- Auto Pagination is recommended and on by default #### Anki Note Options -- Controls what is saved to Anki -- You can change the base Anki deck - - Anknotes can append the base deck with the Evernote note's Notebook Stack and Notebook Name - - Any colon will be converted to two colons, to enable Anki's sub-deck functionality -- You can change which Evernote tags are saved - -#### Note Updating -- By default, Anknotes will update existing Anki notes in place. This preserves all Anki statistics. +- Controls what is saved to Anki +- You can change the base Anki deck + - Anknotes can append the base deck with the Evernote note's Notebook Stack and Notebook Name + - Any colon will be converted to two colons, to enable Anki's sub-deck functionality +- You can change which Evernote tags are saved +#### Note Updating +- By default, Anknotes will update existing Anki notes in place. This preserves all Anki statistics. - You can also ignore existing notes, or delete and re-add existing notes (this will erase any Anki statistics) - + ## Details #### Anki Templates - All use an advanced Anki template with customized content and CSS - Reversible notes will generate a normal and reversed card for each note - - Add `#Reversible` tag to Evernote note before importing -- Reverse-only notes will only generate a reversed card - - Add `#Reverse-Only` tag to Evernote note before importing + - Add `#Reversible` tag to Evernote note before importing +- Reverse-only notes will only generate a reversed card + - Add `#Reverse-Only` tag to Evernote note before importing - [Cloze notes] (#post-process-cloze) are automatically detected by Anknotes #### Auto Import -1. Automatically import on profile load - - Enable via Anknotes Menu - - Auto Import will be delayed if an import has occurred in the past 30 minutes +1. Automatically import on profile load + - Enable via Anknotes Menu + - Auto Import will be delayed if an import has occurred in the past 30 minutes 1. Automatically page through an Evernote query - - Enable via Anknotes Settings - - Evernote only returns 250 results per search, so queries with > 250 possible results require multiple searches - - If more than 10 API calls are made during a search, the next search is delayed by 15 minutes + - Enable via Anknotes Settings + - Evernote only returns 250 results per search, so queries with > 250 possible results require multiple searches + - If more than 10 API calls are made during a search, the next search is delayed by 15 minutes 1. Automatically import continuously - Only configurable via source code at this time - - Enable Auto Import and Pagination as per above, and then modify `constants.py`, setting `PAGING_RESTART_WHEN_COMPLETE` to `True` - + - Enable Auto Import and Pagination as per above, and then modify `constants.py`, setting `PAGING_RESTART_WHEN_COMPLETE` to `True` #### Note Processing Features 1. Fix [Evernote Note Links] (https://dev.evernote.com/doc/articles/note_links.php) so that they can be opened in Anki <a id='post-process-links' /> - - Convert "New Style" Evernote web links to "Classic" Evernote in-app links so that any note links open directly in Evernote - - Convert all Evernote links to use two forward slashes instead of three to get around an Anki bug + - Convert "New Style" Evernote web links to "Classic" Evernote in-app links so that any note links open directly in Evernote + - Convert all Evernote links to use two forward slashes instead of three to get around an Anki bug 1. Automatically embed images <a id='post-process-images' /> - This is a workaround since Anki cannot import Evernote resources such as embedded images, PDF files, sounds, etc - Anknotes will convert any of the following to embedded, linkable images: - Any HTML Dropbox sharing link to an image `(https://www.dropbox.com/s/...)` - Any Dropbox plain-text to an image (same as above, but plain-text links must end with `?dl=0` or `?dl=1`) - - Any HTML link with Link Text beginning with "Image Link", e.g.: `<a href='http://www.foo.com/bar'>Image Link #1</a>` + - Any HTML link with Link Text beginning with "Image Link", e.g.: `<a href='http://www.foo.com/bar'>Image Link #1</a>` 1. Occlude (hide) certain text on fronts of Anki cards <a id='post-process-occlude' /> - Useful for displaying additional information but ensuring it only shows on backs of cards - Anknotes converts any of the following to special text that will display in grey color, and only on the backs of cards: - Any text with white foreground - - Any text within two brackets, such as `<<Hide Me>>` + - Any text within two brackets, such as `<<Hide Me>>` 1. Automatically generate [Cloze fields] (http://ankisrs.net/docs/manual.html#cloze) <a id='post-process-cloze' /> - Any text with a single curly bracket will be converted into a cloze field - E.g., two cloze fields are generated from: The central nervous system is made up of the `{brain}` and `{spinal cord}` - If you want to generate a single cloze field (not increment the field #), insert a pound character `('#')` after the first curly bracket: - E.g., a single cloze field is generated from: The central nervous system is made up of the `{brain}` and `{#spinal cord}` - + ##Beta Functions -#### Note Creation -- Anknotes can create and upload/update existing Evernote notes -- Currently this is limited to creating new Auto TOC notes and modifying the See Also Footer field of existing notes -- Anknotes uses client-side validation to decrease API usage, but there is currently an issue with use of the validation library in Anki. - - So, Anknotes will execute this validation using an **external** script, not as an Anki addon - - Therefore, you must **manually** ensure that **Python** and the **lxml** module is installed on your system +#### Note Creation +- Anknotes can create and upload/update existing Evernote notes +- Currently this is limited to creating new Auto TOC notes and modifying the See Also Footer field of existing notes +- Anknotes uses client-side validation to decrease API usage, but there is currently an issue with use of the validation library in Anki. + - So, Anknotes will execute this validation using an **external** script, not as an Anki addon + - Therefore, you must **manually** ensure that **Python** and the **lxml** module is installed on your system - Alternately, disable validation: Edit `constants.py` and set `ENABLE_VALIDATION` to `False` -#### Find Deleted/Orphaned Notes +#### Find Deleted/Orphaned Notes - Anknotes is not intended for use as a sync client with Evernote (this may change in the future) - Thus, notes deleted from the Evernote servers will not be deleted from Anki - Use `Anknotes → Maintenance Tasks → Find Deleted Notes` to find and delete these notes from Anki - - You can also find notes in Evernote that don't exist in Anki + - You can also find notes in Evernote that don't exist in Anki - First, you must create a "Table of Contents" note using the Evernote desktop application: - In the Windows client, select ALL notes you want imported into Anki, and click the `Create Table of Contents Note` button on the right-sided panel - - Alternately, select 'Copy Note Links' and paste the content into a new Evernote Note. + - Alternately, select 'Copy Note Links' and paste the content into a new Evernote Note. - Export your Evernote note to `anknotes/extra/user/Table of Contents.enex` - + ## "See Also" Footer Links -#### Concept +#### Concept - You have topics (**Root Notes**) broken down into multiple sub-topics (**Sub Notes**) - - The Root Notes are too broad to be tested, and therefore not useful as Anki cards - - The Sub Notes are testable topics intended to be used as Anki cards -- Anknotes tries to link these related Sub Notes together so you can rapidly view related content in Evernote - -#### Terms + - The Root Notes are too broad to be tested, and therefore not useful as Anki cards + - The Sub Notes are testable topics intended to be used as Anki cards +- Anknotes tries to link these related Sub Notes together so you can rapidly view related content in Evernote +#### Terms 1. **Table of Contents (TOC) Notes** - Primarily contain a hierarchical list of links to other notes 2. **Outline Notes** - Primarily contain content itself of sub-notes - E.g. a summary of sub-notes or full text of sub-notes - Common usage scenario is creating a broad **Outline** style note when studying a topic, and breaking that down into multiple **Sub Notes** to use in Anki -3. **"See Also" Footer** Fields - - Primarily consist of links to TOC notes, Outline notes, or other Evernote notes +3. **"See Also" Footer** Fields + - Primarily consist of links to TOC notes, Outline notes, or other Evernote notes 4. **Root Titles** and **Sub Notes** - - Sub Notes are notes with a colon in the title + - Sub Notes are notes with a colon in the title - Root Title is the portion of the title before the first colon - -#### Integration + +#### Integration ###### With Anki: - The **"See Also" Footer** field is shown on the backs of Anki cards only, so having a descriptive link in here won't give away the correct answer -- The content itself of **TOC** and **Outline** notes are also viewable on the backs of Anki cards - +- The content itself of **TOC** and **Outline** notes are also viewable on the backs of Anki cards ##### With Evernote: - Anknotes can create new Evernote notes from automatically generated TOC notes - Anknotes can update existing Evernote notes with modified See Also Footer fields #### Usage -###### Manual Usage: +###### Manual Usage: - Add a new line to the end of your Evernote note that begins with `See Also`, and include relevant links after it -- Tag notes in Evernote before importing. - - Table of Contents (TOC) notes are designated by the `#TOC` tag. - - Outline notes are designed by the `#Outline` tag. - -###### Automated Usage: +- Tag notes in Evernote before importing. + - Table of Contents (TOC) notes are designated by the `#TOC` tag. + - Outline notes are designed by the `#Outline` tag. +###### Automated Usage: - Anknotes can automatically create: - Table of Contents Notes - Created for **Root Titles** containing two or more Sub Notes - - In Anki, click the `Anknotes Menu → Process See Also Footer Links → Step 3: Create Auto TOC Notes`. - - Once the Auto TOC notes are generated, click `Steps 4 & 5` to upload the notes to Evernote + - In Anki, click the `Anknotes Menu → Process See Also Footer Links → Step 3: Create Auto TOC Notes`. + - Once the Auto TOC notes are generated, click `Steps 4 & 5` to upload the notes to Evernote - See Also' Footer fields for displaying links to other Evernote notes - Any links from other notes, including automatically generated TOC notes, are inserted into this field by Anknotes - - Creation of Outline notes from sub-notes or sub-notes from outline notes is a possible future feature - + - Creation of Outline notes from sub-notes or sub-notes from outline notes is a possible future feature #### Example: Let's say we have nine **Sub Notes** titled `Diabetes: Symptoms`, `Diabetes: Treatment`, `Diabetes: Treatment: Types of Insulin`, and `Diabetes: Complications`, etc: - Anknotes will generate a TOC note **`Diabetes`** with hierarchical links to all nine sub-notes as such: > DIABETES - > 1. Symptoms - > 2. Complications + > 1. Symptoms + > 2. Complications > 1. Cardiovascular - > * Heart Attack Risk + > * Heart Attack Risk > 2. Infectious > 3. Ophthalmologic - > 3. Treatment - > * Types of Insulin - -- Anknotes can then insert a link to that TOC note in the 'See Also' Footer field of the sub notes -- This 'See Also' Footer field will display on the backs of Anki cards + > 3. Treatment + > * Types of Insulin +- Anknotes can then insert a link to that TOC note in the 'See Also' Footer field of the sub notes +- This 'See Also' Footer field will display on the backs of Anki cards - The TOC note's contents themselves will also be available on the backs of Anki cards ## Future Features - More robust options - Move options from source code into GUI - - Allow enabling/disabling of beta functions like See Also fields + - Allow enabling/disabling of beta functions like See Also fields - Customize criteria for detecting see also fields -- Implement full sync with Evernote servers +- Implement full sync with Evernote servers - Import resources (e.g., images, sounds, etc) from Evernote notes - Automatically create Anki sub-notes from a large Evernote note - + ## Developer Notes #### Anki Template / CSS Files: - Template File Location: `/extra/ancillary/FrontTemplate.htm` @@ -207,13 +197,12 @@ Let's say we have nine **Sub Notes** titled `Diabetes: Symptoms`, `Diabetes: Tre - Message Box CSS: `/extra/ancillary/QMessageBox.css` #### Anknotes Local Database -- Anknotes saves all Evernote notes, tags, and notebooks in the SQL database of the active Anki profile +- Anknotes saves all Evernote notes, tags, and notebooks in the SQL database of the active Anki profile - You may force a resync with the local Anknotes database via the menu: `Anknotes → Maintenance Tasks` - - You may force update of ancillary tag/notebook data via this menu -- Maps of see also footer links and Table of Contents notes are also saved here -- All Evernote note history is saved in a separate table. This is not currently used but may be helpful if data loss occurs or for future functionality - -#### Developer Functions + - You may force update of ancillary tag/notebook data via this menu +- Maps of see also footer links and Table of Contents notes are also saved here +- All Evernote note history is saved in a separate table. This is not currently used but may be helpful if data loss occurs or for future functionality +#### Developer Functions - If you are testing a new feature, you can automatically have Anki run that function when Anki starts. - Simply add the method to `__main__.py` under the comment `Add a function here and it will automatically run on profile load` - Also, create the folder `/anknotes/extra/dev` and add files `anknotes.developer` and `anknotes.developer.automate` \ No newline at end of file diff --git a/anknotes/Anki.py b/anknotes/Anki.py index 9df6479..f32f46e 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -12,7 +12,7 @@ from anknotes.AnkiNotePrototype import AnkiNotePrototype from anknotes.shared import * from anknotes import stopwatch -### Evernote Imports +### Evernote Imports # from evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec # from evernote.edam.type.ttypes import NoteSortOrder, Note # from evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, EDAMNotFoundException @@ -105,15 +105,14 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang baseNote = None if update: baseNote = self.get_anki_note_from_evernote_guid(ankiNote.Guid) - if not baseNote: + if not baseNote: log_error('Updating note %s: COULD NOT FIND BASE NOTE FOR ANKI NOTE ID' % ankiNote.Guid) tmr.reportStatus(EvernoteAPIStatus.MissingDataError) - continue - + continue if ankiNote.Tags is None: log_error("Could note find tags object for note %s: %s. " % (ankiNote.Guid, ankiNote.FullTitle)) tmr.reportStatus(EvernoteAPIStatus.MissingDataError) - continue + continue anki_note_prototype = AnkiNotePrototype(self, anki_field_info, ankiNote.TagNames, baseNote, notebookGuid=ankiNote.NotebookGuid, count=tmr.count, count_update=tmr.counts.success, max_count=tmr.counts.max.val) @@ -133,9 +132,9 @@ def delete_anki_cards(self, evernote_guids): def get_evernote_model_styles(): if MODELS.OPTIONS.IMPORT_STYLES: return '@import url("%s");' % FILES.ANCILLARY.CSS return file(os.path.join(FOLDERS.ANCILLARY, FILES.ANCILLARY.CSS), 'r').read() - - def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): - model = mm.byName(modelName) + + def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): + model = mm.byName(modelName) model_css = self.get_evernote_model_styles() templates = self.get_templates(modelName==MODELS.DEFAULT) if model and modelName is MODELS.DEFAULT: @@ -143,12 +142,12 @@ def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): evernote_account_info = get_evernote_account_ids() if not evernote_account_info.Valid: info = ankDB().first("SELECT uid, shard, COUNT(uid) as c1, COUNT(shard) as c2 from %s GROUP BY uid, shard ORDER BY c1 DESC, c2 DESC LIMIT 1" % TABLES.SEE_ALSO) - if info and evernote_account_info.update(info[0], info[1]): forceRebuild = True + if info and evernote_account_info.update(info[0], info[1]): forceRebuild = True if evernote_account_info.Valid: - if not "evernote_uid = '%s'" % evernote_account_info.uid in front or not "evernote_shard = '%s'" % evernote_account_info.shard in front: forceRebuild = True - if model['css'] != model_css: forceRebuild = True - if model['tmpls'][0]['qfmt'] != templates['Front']: forceRebuild = True - if not model or forceRebuild: + if not "evernote_uid = '%s'" % evernote_account_info.uid in front or not "evernote_shard = '%s'" % evernote_account_info.shard in front: forceRebuild = True + if model['css'] != model_css: forceRebuild = True + if model['tmpls'][0]['qfmt'] != templates['Front']: forceRebuild = True + if not model or forceRebuild: if model: for t in model['tmpls']: t['qfmt'] = templates['Front'] @@ -156,8 +155,7 @@ def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): model['css'] = model_css mm.update(model) else: - model = mm.new(modelName) - + model = mm.new(modelName) # Add Field for Evernote GUID: # Note that this field is first because Anki requires the first field to be unique evernote_guid_field = mm.newField(FIELDS.EVERNOTE_GUID) @@ -233,7 +231,7 @@ def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): self.evernoteModels[modelName] = model['id'] return forceRebuild - def get_templates(self, forceRebuild=False): + def get_templates(self, forceRebuild=False): if not self.templates or forceRebuild: evernote_account_info = get_evernote_account_ids() field_names = { @@ -251,14 +249,14 @@ def add_evernote_models(self): col = self.collection() mm = col.models self.evernoteModels = {} - + forceRebuild = self.add_evernote_model(mm, MODELS.DEFAULT) self.add_evernote_model(mm, MODELS.REVERSE_ONLY, forceRebuild) self.add_evernote_model(mm, MODELS.REVERSIBLE, forceRebuild) self.add_evernote_model(mm, MODELS.CLOZE, forceRebuild, True) def setup_ancillary_files(self): - # Copy CSS file from anknotes addon directory to media directory + # Copy CSS file from anknotes addon directory to media directory media_dir = re.sub("(?i)\.(anki2)$", ".media", self.collection().path) if isinstance(media_dir, str): media_dir = unicode(media_dir, sys.getfilesystemencoding()) @@ -373,16 +371,16 @@ def insert_toc_into_see_also(self): TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES, TABLES.EVERNOTE.NOTES)) all_guids = [x[0] for x in db.all("SELECT guid FROM %s WHERE tagNames NOT LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, TAGS.TOC))] grouped_results = {} - + toc_titles = {} for row in results: key = row[0] value = row[1] - if key not in all_guids: continue + if key not in all_guids: continue toc_titles[value] = row[2] if key not in grouped_results: grouped_results[key] = [row[3], []] grouped_results[key][1].append(value) - # log_dump(grouped_results, 'grouped_results', 'insert_toc', timestamp=False) + # log_dump(grouped_results, 'grouped_results', 'insert_toc', timestamp=False) log.banner('INSERT TOCS INTO ANKI NOTES: %d NOTES' % len(grouped_results), 'insert_toc') toc_separator = generate_evernote_span(u' | ', u'Links', u'See Also', bold=False) count = 0 @@ -400,10 +398,10 @@ def insert_toc_into_see_also(self): fields = get_dict_from_list(ankiNote.items()) see_also_html = fields[FIELDS.SEE_ALSO] content_links = find_evernote_links_as_guids(fields[FIELDS.CONTENT]) - see_also_links = find_evernote_links_as_guids(see_also_html) + see_also_links = find_evernote_links_as_guids(see_also_html) new_tocs = set(toc_guids) - set(see_also_links) - set(content_links) log.dump([new_tocs, toc_guids, see_also_links, content_links], 'TOCs for %s' % fields[FIELDS.TITLE] + ' vs ' + note_title , 'insert_toc_new_tocs', crosspost_to_default=False) - new_toc_count = len(new_tocs) + new_toc_count = len(new_tocs) if new_toc_count > 0: see_also_count = len(see_also_links) has_ol = u'<ol' in see_also_html @@ -420,7 +418,7 @@ def insert_toc_into_see_also(self): see_also_new += (toc_delimiter + toc_link) if flat_links else (u'\n<li>%s</li>' % toc_link) toc_delimiter = toc_separator if flat_links: - find_div_end = see_also_html.rfind('</div>') + find_div_end = see_also_html.rfind('</div>') if find_div_end > -1: see_also_html = see_also_html[:find_div_end] + see_also_new + '\n' + see_also_html[find_div_end:] see_also_new = '' @@ -430,11 +428,11 @@ def insert_toc_into_see_also(self): see_also_toc_headers['ul'] = see_also_toc_headers['ol'].replace('<ol ', '<ul ') if see_also_toc_headers['ul'] in see_also_html: - find_ul_end = see_also_html.rfind('</ul>') + find_ul_end = see_also_html.rfind('</ul>') see_also_html = see_also_html[:find_ul_end] + '</ol>' + see_also_html[find_ul_end + 5:] see_also_html = see_also_html.replace(see_also_toc_headers['ul'], see_also_toc_headers['ol']) if see_also_toc_headers['ol'] in see_also_html: - find_ol_end = see_also_html.rfind('</ol>') + find_ol_end = see_also_html.rfind('</ol>') see_also_html = see_also_html[:find_ol_end] + see_also_new + '\n' + see_also_html[find_ol_end:] see_also_new = '' else: @@ -468,7 +466,7 @@ def extract_links_from_toc(self): for enLink in find_evernote_links(toc_entry['content']): target_evernote_guid = enLink.Guid link_number = 1 + ankDB().scalar("select COUNT(*) from %s WHERE source_evernote_guid = '%s' " % ( - TABLES.SEE_ALSO, target_evernote_guid)) + TABLES.SEE_ALSO, target_evernote_guid)) query = """INSERT INTO `%s`(`source_evernote_guid`, `number`, `uid`, `shard`, `target_evernote_guid`, `html`, `title`, `from_toc`, `is_toc`) SELECT '%s', %d, %d, '%s', '%s', '%s', '%s', 1, 1 FROM `%s` WHERE NOT EXISTS (SELECT * FROM `%s` WHERE `source_evernote_guid`='%s' AND `target_evernote_guid`='%s') LIMIT 1 """ % ( TABLES.SEE_ALSO, target_evernote_guid, link_number, enLink.Uid, enLink.Shard, toc_evernote_guid, toc_link_html.replace(u'\'', u'\'\''), toc_link_title.replace(u'\'', u'\'\''), TABLES.SEE_ALSO, diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 6b7dd4f..7eb3752 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -47,7 +47,7 @@ class Counts: OriginalGuid = None """:type : str""" - Changed = False + Changed = False _unprocessed_content_ = "" _unprocessed_see_also_ = "" _log_update_if_unchanged_ = True @@ -101,7 +101,7 @@ def __init__(self, anki=None, fields=None, tags=None, base_note=None, notebookGu log(self.NotebookGuid) raise ValueError self._deck_parent_ = self.Anki.deck if self.Anki else '' - assert tags is not None + assert tags is not None self.Tags = tags self.__cloze_count__ = 0 self.process_note() @@ -201,22 +201,22 @@ def step_3_occlude_text(): # I currently use white text in Evernote to display information that I want to be initially hidden, but visible when desired by selecting the white text. # We will change the white text to a special "occluded" CSS class so it can be visible on the back of cards, and also so we can adjust the color for the front of cards when using night mode self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') - + ################################### Step 4: Automatically Occlude Text in <<Double Angle Brackets>> self.Fields[FIELDS.CONTENT] = re.sub("(?s)(?P<Prefix><|<) ?(?P=Prefix) ?(?P<PrefixKeep>(?:</div>)?)(?P<OccludedText>.+?)(?P<Suffix>>|>) ?(?P=Suffix) ?", r'<<\g<PrefixKeep><div class="occluded">\g<OccludedText></div>>>', self.Fields[FIELDS.CONTENT]) - + def step_5_create_cloze_fields(): ################################### Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. self.Fields[FIELDS.CONTENT] = re.sub(r'([^{]){([^{].*?)}([^}])', self.evernote_cloze_regex, self.Fields[FIELDS.CONTENT]) def step_6_process_see_also_links(): ################################### Step 6: Process "See Also: " Links - see_also_match = regex_see_also().search(self.Fields[FIELDS.CONTENT]) - if not see_also_match: + see_also_match = regex_see_also().search(self.Fields[FIELDS.CONTENT]) + if not see_also_match: if self.Fields[FIELDS.CONTENT].find("See Also") > -1: log("No See Also Content Found, but phrase 'See Also' exists in " + self.FullTitle + " \n" + self.Fields[FIELDS.CONTENT]) - raise ValueError - return + raise ValueError + return self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group(0), see_also_match.group('Suffix')) self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<div><b><br/></b></div></en-note>', '</en-note>') see_also = see_also_match.group('SeeAlso') @@ -229,13 +229,13 @@ def step_6_process_see_also_links(): self.Fields[FIELDS.SEE_ALSO] += see_also if self.light_processing: self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group('Suffix'), self.Fields[FIELDS.SEE_ALSO] + see_also_match.group('Suffix')) - return + return self.process_note_see_also() - + if not FIELDS.CONTENT in self.Fields: return self._unprocessed_content_ = self.Fields[FIELDS.CONTENT] - self._unprocessed_see_also_ = self.Fields[FIELDS.SEE_ALSO] + self._unprocessed_see_also_ = self.Fields[FIELDS.SEE_ALSO] steps = [0, 1, 6] if self.light_processing else range(0,7) if self.light_processing and not ANKI.NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING: steps.remove(0) @@ -250,7 +250,7 @@ def step_6_process_see_also_links(): ################################### Note Processing complete. def detect_note_model(self): - + # log('Title, self.model_name, tags, self.model_name', 'detectnotemodel') # log(self.Fields[FIELDS.TITLE], 'detectnotemodel') # log(self.ModelName, 'detectnotemodel') @@ -274,8 +274,8 @@ def model_id(self): if not self.ModelName: return None return long(self.Anki.models().byName(self.ModelName)['id']) - def process_note(self): - self.process_note_content() + def process_note(self): + self.process_note_content() if not self.light_processing: self.detect_note_model() @@ -364,7 +364,7 @@ def update_note_deck(self): def update_note_fields(self): fields_to_update = [FIELDS.TITLE, FIELDS.CONTENT, FIELDS.SEE_ALSO, FIELDS.UPDATE_SEQUENCE_NUM] fld_content_ord = -1 - flag_changed = False + flag_changed = False field_updates = [] fields_updated = {} for fld in self.note._model['flds']: @@ -402,7 +402,7 @@ def update_note_fields(self): if flag_changed: self.Changed = True return flag_changed - def update_note(self): + def update_note(self): self.note = self.BaseNote self.logged = False if not self.BaseNote: @@ -416,10 +416,9 @@ def update_note(self): if not (self.Changed or self.update_note_deck()): if self._log_update_if_unchanged_: self.log_update("Not updating Note: The fields, tags, and deck are the same") - elif ( - self.Counts.Updated is 0 or self.Counts.Current / self.Counts.Updated > 9) and self.Counts.Current % 100 is 0: + elif (self.Counts.Updated is 0 or self.Counts.Current / self.Counts.Updated > 9) and self.Counts.Current % 100 is 0: self.log_update() - return False + return False if not self.Changed: # i.e., the note deck has been changed but the tags and fields have not self.Counts.Updated += 1 @@ -437,23 +436,22 @@ def update_note(self): self.Counts.Updated += 1 return True - + def check_titles_equal(self, old_title, new_title, new_guid, log_title='DB INFO UNEQUAL'): - do_log_title = False + do_log_title = False if not isinstance(new_title, unicode): try: new_title = unicode(new_title, 'utf-8') - except: do_log_title = True + except: do_log_title = True if not isinstance(old_title, unicode): try: old_title = unicode(old_title, 'utf-8') - except: do_log_title = True + except: do_log_title = True guid_text = '' if self.OriginalGuid is None else ' ' + self.OriginalGuid + ('' if new_guid == self.OriginalGuid else ' vs %s' % new_guid) + ':' - if do_log_title or new_title != old_title or new_guid != self.OriginalGuid: - log_str = ' %s: %s%s' % (log_title, guid_text, ' ' + new_title + ' vs ' + old_title) - log_error(log_str) + if do_log_title or new_title != old_title or (self.OriginalGuid and new_guid != self.OriginalGuid): + log_str = ' %s: %s%s' % ('*' if do_log_title else ' ' + log_title, guid_text, ' ' + new_title + ' vs ' + old_title) + log_error(log_str, crosspost_to_default=False) self.log_update(log_str) - return False - return True - + return False + return True @property def Title(self): """:rtype : EvernoteNoteTitle.EvernoteNoteTitle """ @@ -466,9 +464,9 @@ def Title(self): @property def FullTitle(self): return self.Title.FullTitle - - - def add_note(self): + + + def add_note(self): self.create_note() if self.note is not None: collection = self.Anki.collection() @@ -479,10 +477,11 @@ def add_note(self): self.check_titles_equal(db_title, self.Fields[FIELDS.TITLE], self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, 'NEW NOTE TITLE ')) try: collection.addNote(self.note) - except: - log_error("Unable to collection.addNote for Note %s: %s" % ( - self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, ''), db_title)) + except Exception, e: + log_error("Unable to collection.addNote: \n - Error: %s\n - GUID: %s\n - Title: %s" % ( + str(type(e)) + ": " + str(e), self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, ''), db_title)) log_dump(self.note.fields, '- FAILED collection.addNote: ') + raise return -1 collection.autosave() self.Anki.start_editing() @@ -497,4 +496,13 @@ def create_note(self): self.note.model()['did'] = id_deck self.note.tags = self.Tags for name, value in self.Fields.items(): - self.note[name] = value + try: self.note[name] = value #.encode('utf-8') + except UnicodeEncodeError, e: + log_error('Create Note Error: %s: %s\n - Message: %s' % (str(type(e)), str(type(value)), str(e))) + raise + except UnicodeDecodeError, e: + log_error('Create Note Error: %s: %s\n - Message: %s' % (str(type(e)), str(type(value)), str(e))) + raise + except Exception, e: + log_error('Create Note Error: %s: %s\n - Message: %s' % (str(type(e)), str(type(value)), str(e))) + raise diff --git a/anknotes/Controller.py b/anknotes/Controller.py index bd94363..32eaa10 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -16,8 +16,7 @@ from anknotes.AnkiNotePrototype import AnkiNotePrototype from anknotes.EvernoteNotePrototype import EvernoteNotePrototype from anknotes.EvernoteNoteTitle import generateTOCTitle -from anknotes import stopwatch - +from anknotes import stopwatch ### Anknotes Main Imports from anknotes.Anki import Anki from anknotes.ankEvernote import Evernote @@ -26,7 +25,7 @@ from anknotes import settings from anknotes.EvernoteImporter import EvernoteImporter -### Evernote Imports +### Evernote Imports from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote from anknotes.evernote.edam.error.ttypes import EDAMSystemException @@ -47,7 +46,7 @@ def __init__(self): self.anki.deck = mw.col.conf.get(SETTINGS.ANKI.DECKS.BASE, SETTINGS.ANKI.DECKS.BASE_DEFAULT_VALUE) self.anki.setup_ancillary_files() ankDB().Init() - self.anki.add_evernote_models() + self.anki.add_evernote_models() self.evernote = Evernote() def test_anki(self, title, evernote_guid, filename=""): @@ -80,16 +79,16 @@ def upload_validated_notes(self, automated=False): for dbRow in dbRows: entry = EvernoteValidationEntry(dbRow) evernote_guid, rootTitle, contents, tagNames, notebookGuid = entry.items() - tagNames = tagNames.split(',') - if not tmr.checkLimits(): break + tagNames = tagNames.split(',') + if not tmr.checkLimits(): break whole_note = tmr.autoStep(self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid, validated=True), rootTitle, evernote_guid) if tmr.report_result == False: raise ValueError if tmr.status.IsDelayableError: break - if not tmr.status.IsSuccess: continue - if not whole_note.tagNames: whole_note.tagNames = tagNames + if not tmr.status.IsSuccess: continue + if not whole_note.tagNames: whole_note.tagNames = tagNames noteFetcher.addNoteFromServerToDB(whole_note, tagNames) note = EvernoteNotePrototype(whole_note=whole_note) - assert whole_note.tagNames + assert whole_note.tagNames assert note.Tags if evernote_guid: notes_updated.append(note) @@ -99,19 +98,19 @@ def upload_validated_notes(self, automated=False): queries2.append([rootTitle, contents]) else: tmr.reportNoBreak() tmr.Report(self.anki.add_evernote_notes(notes_created) if tmr.counts.created else 0, self.anki.update_evernote_notes(notes_updated) if tmr.counts.updated else 0) - if tmr.counts.created.completed.subcount: ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries2) - if tmr.counts.updated.completed.subcount: ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries1) + if tmr.counts.created.completed.subcount: ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries2) + if tmr.counts.updated.completed.subcount: ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries1) if tmr.is_success: ankDB().commit() - if tmr.should_retry: mw.progress.timer((30 if tmr.status.IsDelayableError else EVERNOTE.UPLOAD.RESTART_INTERVAL) * 1000, lambda: self.upload_validated_notes(True), False) + if tmr.should_retry: mw.progress.timer((30 if tmr.status.IsDelayableError else EVERNOTE.UPLOAD.RESTART_INTERVAL) * 1000, lambda: self.upload_validated_notes(True), False) return tmr.status, tmr.count, 0 def create_auto_toc(self): - def check_old_values(): + def check_old_values(): old_values = ankDB().first( "SELECT guid, content FROM %s WHERE UPPER(title) = ? AND tagNames LIKE '%%,' || ? || ',%%'" % TABLES.EVERNOTE.NOTES, - rootTitle.upper(), TAGS.AUTO_TOC) - if not old_values: - log(rootTitle, 'AutoTOC-Create\\Add') + rootTitle.upper(), TAGS.AUTO_TOC) + if not old_values: + log(rootTitle, 'AutoTOC-Create\\Add') return None, contents evernote_guid, old_content = old_values # log(['old contents exist', old_values is None, old_values, evernote_guid, old_content]) @@ -122,13 +121,13 @@ def check_old_values(): old_content = old_content.replace('guid-pending', evernote_guid).replace("'", '"') noteBodyUnencoded = noteBodyUnencoded.replace('guid-pending', evernote_guid).replace("'", '"') if old_content == noteBodyUnencoded: - log(rootTitle, 'AutoTOC-Create\\Skipped') + log(rootTitle, 'AutoTOC-Create\\Skipped') tmr.reportSkipped() - return None, None + return None, None log(noteBodyUnencoded, 'AutoTOC-Create\\Update\\New\\'+rootTitle, clear=True) - log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create\\Update\\Diffs\\'+rootTitle, clear=True) + log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create\\Update\\Diffs\\'+rootTitle, clear=True) return evernote_guid, contents.replace('/guid-pending/', '/%s/' % evernote_guid).replace('/guid-pending/', '/%s/' % evernote_guid) - + update_regex() NotesDB = EvernoteNotes() NotesDB.baseQuery = ANKNOTES.HIERARCHY.ROOT_TITLES_BASE_QUERY @@ -138,21 +137,21 @@ def check_old_values(): :type: (list[EvernoteNote], list[EvernoteNote]) """ info = stopwatch.ActionInfo('Creation of Table of Content Note(s)', row_source='Root Title(s)', enabled=EVERNOTE.UPLOAD.ENABLED) - tmr = stopwatch.Timer(len(dbRows), 25, info, max_allowed=EVERNOTE.UPLOAD.MAX) - tmr.label = 'create-auto_toc' + tmr = stopwatch.Timer(len(dbRows), 25, info, max_allowed=EVERNOTE.UPLOAD.MAX) + tmr.label = 'create-auto_toc' if tmr.actionInitializationFailed: return tmr.tmr.status, 0, 0 for dbRow in dbRows: evernote_guid = None rootTitle, contents, tagNames, notebookGuid = dbRow.items() tagNames = (set(tagNames[1:-1].split(',')) | {TAGS.TOC, TAGS.AUTO_TOC} | ({"#Sandbox"} if EVERNOTE.API.IS_SANDBOXED else set())) - {TAGS.REVERSIBLE, TAGS.REVERSE_ONLY} - rootTitle = generateTOCTitle(rootTitle) + rootTitle = generateTOCTitle(rootTitle) evernote_guid, contents = check_old_values() - if contents is None: continue - if not tmr.checkLimits(): break + if contents is None: continue + if not tmr.checkLimits(): break whole_note = tmr.autoStep(self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid), rootTitle, evernote_guid) if tmr.report_result == False: raise ValueError if tmr.status.IsDelayableError: break - if not tmr.status.IsSuccess: continue + if not tmr.status.IsSuccess: continue (notes_updated if evernote_guid else notes_created).append(EvernoteNotePrototype(whole_note=whole_note)) tmr.Report(self.anki.add_evernote_notes(notes_created) if tmr.counts.created.completed else 0, self.anki.update_evernote_notes(notes_updated) if tmr.counts.updated.completed else 0) if tmr.counts.queued: ankDB().commit() @@ -175,9 +174,9 @@ def proceed(self, auto_paging=False): self.evernoteImporter.ManualGUIDs = self.ManualGUIDs self.evernoteImporter.proceed(auto_paging) - def resync_with_local_db(self): - evernote_guids = get_all_local_db_guids() - results = self.evernote.create_evernote_notes(evernote_guids, use_local_db_only=True) + def resync_with_local_db(self): + evernote_guids = get_all_local_db_guids() + results = self.evernote.create_evernote_notes(evernote_guids, use_local_db_only=True) """:type: EvernoteNoteFetcherResults""" show_report('Resync with Local DB: Starting Anki Update of %d Note(s)' % len(evernote_guids)) number = self.anki.update_evernote_notes(results.Notes, log_update_if_unchanged=False) diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index 3427853..f0a393d 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -23,7 +23,7 @@ try: from anknotes import settings except: pass -### Evernote Imports +### Evernote Imports from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec, NoteMetadata, NotesMetadataList from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote from anknotes.evernote.edam.error.ttypes import EDAMSystemException @@ -90,20 +90,20 @@ def get_evernote_metadata(self): :type: NotesMetadataList """ except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise self.MetadataProgress.Status = EvernoteAPIStatus.RateLimitError return False except socket.error, v: - if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise self.MetadataProgress.Status = EvernoteAPIStatus.SocketError return False self.MetadataProgress.loadResults(result) self.evernote.metadata = self.MetadataProgress.NotesMetadata - log(" " * 32 + "- Metadata Results: %s" % self.MetadataProgress.Summary, timestamp=False) + log(self.MetadataProgress.Summary, line_padding_header="- Metadata Results: ", line_padding=32, timestamp=False) return True def update_in_anki(self, evernote_guids): - """ + """ :rtype : EvernoteNoteFetcherResults """ Results = self.evernote.create_evernote_notes(evernote_guids) @@ -114,7 +114,7 @@ def update_in_anki(self, evernote_guids): return Results def import_into_anki(self, evernote_guids): - """ + """ :rtype : EvernoteNoteFetcherResults """ Results = self.evernote.create_evernote_notes(evernote_guids) @@ -174,7 +174,7 @@ def proceed_start(self, auto_paging=False): if lastImportStr: lastImportStr = ' [LAST IMPORT: %s]' % lastImportStr log("! > Starting Evernote Import: Page #%d: %-60s%s" % ( self.currentPage, settings.generate_evernote_query(), lastImportStr)) - log("-"*181, timestamp=False) + log("-"*186, timestamp=False) if not auto_paging: note_store_status = self.evernote.initialize_note_store() if not note_store_status == EvernoteAPIStatus.Success: @@ -211,7 +211,9 @@ def proceed_find_metadata(self, auto_paging=False): self.ImportProgress = EvernoteImportProgress(self.anki, self.MetadataProgress) self.ImportProgress.loadAlreadyUpdated( [] if self.ManualMetadataMode else self.check_note_sync_status(self.ImportProgress.GUIDs.Server.Existing.All)) - log(" - " + self.ImportProgress.Summary + "\n", timestamp=False) + # log(self.MetadataProgress.Summary, line_padding_header="- Metadata Results: ", line_padding=32, timestamp=False) + log(self.ImportProgress.Summary + "\n", line_padding_header="- Note Sync Status: ", line_padding=32, timestamp=False) + # log(" " * 32 + "- " + self.ImportProgress.Summary + "\n", timestamp=False) def proceed_import_notes(self): self.anki.start_editing() @@ -227,12 +229,18 @@ def proceed_import_notes(self): self.anki.stop_editing() self.anki.collection().autosave() + def save_current_page(self): + if self.forceAutoPage: return + col = self.anki.collection() + col.conf[SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE] = self.currentPage + col.setMod() + col.save() def proceed_autopage(self): if not self.autoPagingEnabled: return global latestEDAMRateLimit, latestSocketError - col = self.anki.collection() status = self.ImportProgress.Status + restart = 0 if status == EvernoteAPIStatus.RateLimitError: m, s = divmod(latestEDAMRateLimit, 60) show_report(" > Error: Delaying Auto Paging", @@ -256,45 +264,39 @@ def proceed_autopage(self): if self.auto_page_callback: self.auto_page_callback() return True - elif col.conf.get(EVERNOTE.IMPORT.PAGING.RESTART.ENABLED, True): + elif mw.col.conf.get(EVERNOTE.IMPORT.PAGING.RESTART.ENABLED, True): restart = max(EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL, 60*15) restart_title = " > Restarting Auto Paging" - restart_msg = "All %d notes have been processed and EVERNOTE.IMPORT.PAGING.RESTART.ENABLED is TRUE<BR>" % \ + restart_msg = "All %d notes have been processed and EVERNOTE.IMPORT.PAGING.RESTART.ENABLED is True<BR>" % \ self.MetadataProgress.Total suffix = "Per EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL, " else: show_report(" > Completed Auto Paging", - "All %d notes have been processed and EVERNOTE.IMPORT.PAGING.RESTART.ENABLED is FALSE" % + "All %d notes have been processed and EVERNOTE.IMPORT.PAGING.RESTART.ENABLED is False" % self.MetadataProgress.Total, delay=5) + self.save_current_page() return True - else: # Paging still in progress + else: # Paging still in progress (else to ) self.currentPage = self.MetadataProgress.Page + 1 restart_title = " > Continuing Auto Paging" - restart_msg = "Page %d completed<BR>%d notes remain<BR>%d of %d notes have been processed" % ( - self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.Completed, + restart_msg = "Page %d completed<BR>%d notes remain over %d page%s<BR>%d of %d notes have been processed" % ( + self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.RemainingPages, 's' if self.MetadataProgress.RemainingPages > 1 else '', self.MetadataProgress.Completed, self.MetadataProgress.Total) - restart = 0 + restart = -1 * max(30, EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL_OVERRIDE) if self.forceAutoPage: - suffix = "<BR>Not delaying as the forceAutoPage flag is set" + suffix = "<BR>Only delaying {interval} as the forceAutoPage flag is set\n" elif self.ImportProgress.APICallCount < EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS: - suffix = "<BR>Not delaying as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS" % ( + suffix = "<BR>Only delaying {interval} as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS\n" % ( self.ImportProgress.APICallCount, EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS) else: - restart = max(EVERNOTE.IMPORT.PAGING.INTERVAL, 60*10) + restart = max(EVERNOTE.IMPORT.PAGING.INTERVAL_SANDBOX,60*5) if EVERNOTE.API.IS_SANDBOXED else max(EVERNOTE.IMPORT.PAGING.INTERVAL, 60*10) suffix = "<BR>Delaying Auto Paging: Per EVERNOTE.IMPORT.PAGING.INTERVAL, " - - if not self.forceAutoPage: - col.conf[SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE] = self.currentPage - col.setMod() - col.save() - - if restart > 0: - m, s = divmod(restart, 60) - suffix += "will delay for %d:%02d min before continuing\n" % (m, s) + self.save_current_page() + if restart > 0: suffix += "will delay for {interval} before continuing\n" + m, s = divmod(abs(restart), 60) + suffix = suffix.format(interval=['%2ds' % s,'%d:%02d min'%(m,s)][m>0]) show_report(restart_title, (restart_msg + suffix).split('<BR>'), delay=5) - if restart > 0: - mw.progress.timer(restart * 1000, lambda: self.proceed(True), False) - return False + if restart: mw.progress.timer(abs(restart) * 1000, lambda: self.proceed(True), False); return False return self.proceed(True) @property diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index 891ec23..e17a56c 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -37,9 +37,8 @@ def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): def __reset_data__(self): self.tagNames = [] - self.tagGuids = [] - self.whole_note = None - + self.tagGuids = [] + self.whole_note = None def UpdateSequenceNum(self): if self.result.Note: return self.result.Note.UpdateSequenceNum @@ -77,19 +76,19 @@ def getNoteLocal(self): return True def setNoteTags(self, tag_names=None, tag_guids=None): - if not self.keepEvernoteTags: + if not self.keepEvernoteTags: self.tagNames = [] self.tagGuids = [] - return - if not tag_names: + return + if not tag_names: if self.tagNames: tag_names = self.tagNames - if not tag_names and self.result.Note: tag_names = self.result.Note.TagNames - if not tag_names and self.whole_note: tag_names = self.whole_note.tagNames - if not tag_names: tag_names = None + if not tag_names and self.result.Note: tag_names = self.result.Note.TagNames + if not tag_names and self.whole_note: tag_names = self.whole_note.tagNames + if not tag_names: tag_names = None # if not tag_names: tag_names = self.tagNames if self.tagNames else (self.result.Note.TagNames if self.result.Note else (self.whole_note.tagNames if self.whole_note else None)) if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else (self.result.Note.TagGuids if self.result.Note else (self.whole_note.tagGuids if self.whole_note else None)) self.tagGuids, self.tagNames = self.evernote.get_matching_tag_data(tag_guids, tag_names) - + def addNoteFromServerToDB(self, whole_note=None, tag_names=None): """ Adds note to Anknote DB from an Evernote Note object provided by the Evernote API @@ -131,17 +130,17 @@ def getNoteRemoteAPICall(self): self.evernote.initialize_note_store() api_action_str = u'trying to retrieve a note. We will save the notes downloaded thus far.' self.api_calls += 1 - log_api(" > getNote [%3d]" % self.api_calls, "GUID: '%s'" % self.evernote_guid) + log_api(" > getNote [%3d]" % self.api_calls, "GUID: '%s'" % self.evernote_guid) try: self.whole_note = self.evernote.noteStore.getNote(self.evernote.token, self.evernote_guid, True, False, False, False) """:type : evernote.edam.type.ttypes.Note""" except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise self.reportResult(EvernoteAPIStatus.RateLimitError) return False except socket.error, v: - if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise self.reportResult(EvernoteAPIStatus.SocketError) return False assert self.whole_note.guid == self.evernote_guid @@ -165,7 +164,7 @@ def setNote(self, whole_note): def getNote(self, evernote_guid=None): self.__reset_data__() - if evernote_guid: + if evernote_guid: self.result.Note = None self.evernote_guid = evernote_guid self.__update_sequence_number__ = self.evernote.metadata[ diff --git a/anknotes/EvernoteNotePrototype.py b/anknotes/EvernoteNotePrototype.py index 9541c46..c3f74a2 100644 --- a/anknotes/EvernoteNotePrototype.py +++ b/anknotes/EvernoteNotePrototype.py @@ -50,7 +50,7 @@ def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid= self.UpdateSequenceNum = whole_note.updateSequenceNum self.Status = EvernoteAPIStatus.Success return - if db_note is not None: + if db_note is not None: self.Title = EvernoteNoteTitle(db_note) db_note_keys = db_note.keys() for key in ['content', 'guid', 'notebookGuid', 'updateSequenceNum', 'tagNames', 'tagGuids']: @@ -82,7 +82,7 @@ def generateLink(self, value=None): def generateLevelLink(self, value=None): return generate_evernote_link_by_level(self.Guid, self.Title.Name, value) - ### Shortcuts to EvernoteNoteTitle Properties; Autogenerated with regex /def +(\w+)\(\)\:/def \1\(\):\r\n\treturn self.Title.\1\r\n/ + ### Shortcuts to EvernoteNoteTitle Properties; Autogenerated with regex /def +(\w+)\(\)\:/def \1\(\):\r\n\treturn self.Title.\1\r\n/ @property def Level(self): return self.Title.Level diff --git a/anknotes/EvernoteNoteTitle.py b/anknotes/EvernoteNoteTitle.py index 0c61061..65976e3 100644 --- a/anknotes/EvernoteNoteTitle.py +++ b/anknotes/EvernoteNoteTitle.py @@ -6,7 +6,7 @@ def generateTOCTitle(title): title = EvernoteNoteTitle.titleObjectToString(title).upper() - for chr in u'αβδφḃ': + for chr in u'αβδφḃ': title = title.replace(chr.upper(), chr) return title diff --git a/anknotes/EvernoteNotes.py b/anknotes/EvernoteNotes.py index 2b9f468..a0d1290 100644 --- a/anknotes/EvernoteNotes.py +++ b/anknotes/EvernoteNotes.py @@ -351,16 +351,16 @@ def processAllRootNotesMissing(self): # childTitle = enChildNote.FullTitle log(" %2d: %d. --> %-60s" % (count_child, level, childBaseTitle), 'RootTitles-TOC', timestamp=False) - # tocList.generateEntry(childTitle, enChildNote) + # tocList.generateEntry(childTitle, enChildNote) tocHierarchy.addNote(enChildNote) realTitle = ankDB().scalar( "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, childGuid)) realTitle = realTitle[0:realTitle.index(':')] # realTitleUTF8 = realTitle.encode('utf8') notebookGuid = sorted(notebookGuids.items(), key=itemgetter(1), reverse=True)[0][0] - + real_root_title = generateTOCTitle(realTitle) - + ol = tocHierarchy.GetOrderedList() tocEntry = EvernoteTOCEntry(real_root_title, ol, ',' + ','.join(tags) + ',', notebookGuid) returns.append(tocEntry) @@ -368,8 +368,7 @@ def processAllRootNotesMissing(self): # ol = realTitleUTF8 # if olsz is None: olsz = ol # olsz += ol - # ol = '<OL>\r\n%s</OL>\r\n' - + # ol = '<OL>\r\n%s</OL>\r\n' # strr = tocHierarchy.__str__() if DEBUG_HTML: ols.append(ol) diff --git a/anknotes/__main__.py b/anknotes/__main__.py index 7e8f6b0..a800840 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -4,11 +4,10 @@ import re, sre_constants try: from pysqlite2 import dbapi2 as sqlite - is_pysqlite = True + is_pysqlite = True except ImportError: from sqlite3 import dbapi2 as sqlite - is_pysqlite = False - + is_pysqlite = False ### Anknotes Shared Imports from anknotes.shared import * @@ -18,14 +17,14 @@ ### Evernote Imports ### Anki Imports -from anki.find import Finder +from anki.find import Finder from anki.hooks import wrap, addHook from aqt.preferences import Preferences from aqt import mw, browser # from aqt.qt import QIcon, QTreeWidget, QTreeWidgetItem from aqt.qt import Qt, QIcon, QTreeWidget, QTreeWidgetItem, QDesktopServices, QUrl from aqt.webview import AnkiWebView -from anki.utils import ids2str, splitFields +from anki.utils import ids2str, splitFields # from aqt.qt.Qt import MatchFlag # from aqt.qt.qt import MatchFlag @@ -35,7 +34,7 @@ def import_timer_toggle(): SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False) if doAutoImport: lastImport = mw.col.conf.get(SETTINGS.EVERNOTE.LAST_IMPORT, None) - importDelay = 0 + importDelay = 0 if lastImport: td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) minimum = timedelta(seconds=max(EVERNOTE.IMPORT.INTERVAL, 20*60)) @@ -53,7 +52,7 @@ def _findEdited((val, args)): try: days = int(val) except ValueError: return return "c.mod > %d" % (time.time() - days * 86400) - + def _findHierarchy((val, args)): if val == 'root': return "n.sfld NOT LIKE '%:%' AND ank.title LIKE '%' || n.sfld || ':%'" @@ -64,7 +63,7 @@ def _findHierarchy((val, args)): if val == 'orphan': return "n.sfld LIKE '%%:%%' AND UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) NOT IN (SELECT UPPER(title) FROM %s WHERE title NOT LIKE '%%:%%' AND tagNames LIKE '%%,%s,%%') " % (TABLES.EVERNOTE.NOTES, TAGS.TOC) # showInfo(val) - + class CallbackItem(QTreeWidgetItem): def __init__(self, root, name, onclick, oncollapse=None): QTreeWidgetItem.__init__(self, root, [name]) @@ -95,14 +94,14 @@ def anknotes_browser_tagtree_wrap(self, root, _old): onclick = lambda c=cmd: self.setFilter(c) widgetItem = QTreeWidgetItem([name]) widgetItem.onclick = onclick - widgetItem.setIcon(0, QIcon(":/icons/" + icon)) + widgetItem.setIcon(0, QIcon(":/icons/" + icon)) root.insertTopLevelItem(index, widgetItem) root = self.CallbackItem(root, _("Anknotes Hierarchy"), None) root.setExpanded(True) root.setIcon(0, icoEvernoteWeb) - for name, cmd in tags[1:]: + for name, cmd in tags[1:]: item = self.CallbackItem(root, name,lambda c=cmd: self.setFilter(c)) - item.setIcon(0, icoEvernoteWeb) + item.setIcon(0, icoEvernoteWeb) return root def _findField(self, field, val, _old=None): @@ -115,12 +114,12 @@ def doCheck(self, field, val): for f in m['flds']: if f['name'].lower() == field: mods[str(m['id'])] = (m, f['ord']) - + if not mods: # nothing has that field return # gather nids - + regex = re.escape(val).replace("\\_", ".").replace("\\%", ".*") sql = """ select id, mid, flds from notes @@ -130,24 +129,23 @@ def doCheck(self, field, val): for (id,mid,flds) in self.col.db.execute(sql, "%"+val+"%"): flds = splitFields(flds) ord = mods[str(mid)][1] - strg = flds[ord] + strg = flds[ord] try: if re.search("(?si)^"+regex+"$", strg): nids.append(id) except sre_constants.error: return if not nids: return "0" - return "n.id in %s" % ids2str(nids) - - # val = doCheck(field, val) + return "n.id in %s" % ids2str(nids) + # val = doCheck(field, val) vtest = doCheck(self, field, val) log("FindField for %s: %s: Total %d matches " %(field, str(val), len(vtest.split(','))), 'sql-finder') - return vtest + return vtest # return _old(self, field, val) - + def anknotes_finder_findCards_wrap(self, query, order=False, _old=None): log("Searching with text " + query , 'sql-finder') "Return a list of card ids for QUERY." tokens = self._tokenize(query) - preds, args = self._where(tokens) + preds, args = self._where(tokens) log("Tokens: %-20s Preds: %-20s Args: %-20s " % (str(tokens), str(preds), str(args)) , 'sql-finder') if preds is None: return [] @@ -162,52 +160,51 @@ def anknotes_finder_findCards_wrap(self, query, order=False, _old=None): return [] if rev: res.reverse() - return res + return res return _old(self, query, order) - + def anknotes_finder_query_wrap(self, preds=None, order=None, _old=None): if _old is None or not isinstance(self, Finder): log_dump([self, preds, order], 'Finder Query Wrap Error', 'finder') return sql = _old(self, preds, order) - if "ank." in preds: + if "ank." in preds: sql = sql.replace("select c.id", "select distinct c.id").replace("from cards c", "from cards c, %s ank" % TABLES.EVERNOTE.NOTES) log('Custom anknotes finder SELECT query: \n%s' % sql, 'sql-finder') elif TABLES.EVERNOTE.NOTES in preds: - log('Custom anknotes finder alternate query: \n%s' % sql, 'sql-finder') + log('Custom anknotes finder alternate query: \n%s' % sql, 'sql-finder') else: log("Anki finder query: %s" % sql, 'sql-finder') - return sql - + return sql def anknotes_search_hook(search): if not 'edited' in search: search['edited'] = _findEdited if not 'hierarchy' in search: search['hierarchy'] = _findHierarchy - + def reset_everything(): ankDB().InitSeeAlso(True) menu.resync_with_local_db() menu.see_also([1, 2, 5, 6, 7]) - + def anknotes_profile_loaded(): if not os.path.exists(os.path.dirname(FILES.USER.LAST_PROFILE_LOCATION)): os.makedirs(os.path.dirname(FILES.USER.LAST_PROFILE_LOCATION)) - with open(FILES.USER.LAST_PROFILE_LOCATION, 'w+') as myFile: + with open(FILES.USER.LAST_PROFILE_LOCATION, 'w+') as myFile: print>> myFile, mw.pm.name menu.anknotes_load_menu_settings() if EVERNOTE.UPLOAD.VALIDATION.ENABLED and EVERNOTE.UPLOAD.VALIDATION.AUTOMATED: menu.upload_validated_notes(True) import_timer_toggle() - + if ANKNOTES.DEVELOPER_MODE.AUTOMATED: ''' For testing purposes only! Add a function here and it will automatically run on profile load - You must create the files 'anknotes.developer' and 'anknotes.developer.automate' in the /extra/dev/ folder - ''' + You must create the files 'anknotes.developer' and 'anknotes.developer.automate' in the /extra/dev/ folder + ''' # reset_everything() menu.see_also([7]) - + # menu.resync_with_local_db() # menu.see_also([1, 2, 5, 6, 7]) # menu.see_also([6, 7]) @@ -220,9 +217,9 @@ def anknotes_profile_loaded(): # menu.see_also([3,4]) # menu.resync_with_local_db() pass - + def anknotes_onload(): - + addHook("profileLoaded", anknotes_profile_loaded) addHook("search", anknotes_search_hook) Finder._query = wrap(Finder._query, anknotes_finder_query_wrap, "around") diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 7327407..a04eeeb 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -2,19 +2,17 @@ ### Python Imports import socket import stopwatch -import sys +import sys from datetime import datetime, timedelta from StringIO import StringIO try: from lxml import etree - import lxml.html as LH eTreeImported = True except ImportError: eTreeImported = False -inAnki='anki' in sys.modules - +inAnki='anki' in sys.modules try: from pysqlite2 import dbapi2 as sqlite except ImportError: @@ -33,7 +31,7 @@ from anknotes.evernote.edam.type.ttypes import Note as EvernoteNote from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException from anknotes.evernote.api.client import EvernoteClient - + from aqt.utils import openLink, getText, showInfo ### Anki Imports @@ -67,7 +65,7 @@ def __init__(self): self.noteStore = None self.getNoteCount = 0 self.hasValidator = eTreeImported - if ankDBIsLocal(): + if ankDBIsLocal(): log("Skipping Evernote client load (DB is Local)", 'client') return self.setup_client() @@ -94,9 +92,9 @@ def setup_client(self): mw.col.conf[SETTINGS.EVERNOTE.AUTH_TOKEN] = auth_token else: client = EvernoteClient(token=auth_token, sandbox=EVERNOTE.API.IS_SANDBOXED) self.token = auth_token - self.client = client + self.client = client log("Set up Evernote Client", 'client') - + def initialize_note_store(self): if self.noteStore: return EvernoteAPIStatus.Success @@ -108,10 +106,10 @@ def initialize_note_store(self): try: self.noteStore = self.client.get_note_store() except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return EvernoteAPIStatus.RateLimitError except socket.error, v: - if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return EvernoteAPIStatus.SocketError return EvernoteAPIStatus.Success @@ -126,16 +124,16 @@ def validateNoteBody(self, noteBody, title="Note Body"): log(' '*7+' > Note Validation: ENML DTD Loaded in %s' % str(timerInterval)) timerInterval.stop() del timerInterval - + noteBody = noteBody.replace('"http://xml.evernote.com/pub/enml2.dtd"', '"%s"' % convert_filename_to_local_link(FILES.ANCILLARY.ENML_DTD) ) parser = etree.XMLParser(dtd_validation=True, attribute_defaults=True) try: - root = etree.fromstring(noteBody, parser) - except Exception as e: + root = etree.fromstring(noteBody, parser) + except Exception as e: log_str = "XML Loading of %s failed.\n - Error Details: %s" % (title, str(e)) log(log_str, "lxml", timestamp=False, do_print=True) log_error(log_str, False) - return False, [log_str] + return False, [log_str] try: success = self.DTD.validate(root) except Exception as e: @@ -175,7 +173,7 @@ def updateNote(self, guid, noteTitle, noteBody, tagNames=list(), parentNotebook= def makeNoteBody(content, resources=None, encode=True): ## Build body of note if resources is None: resources = [] - nBody = content + nBody = content if not nBody.startswith("<?xml"): nBody = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" nBody += "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" @@ -220,7 +218,7 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot Send Note object to user's account :type noteTitle: str :param noteContents: Valid ENML without the <en-note></en-note> tags. Will be processed by makeNoteBody - :type enNote : EvernoteNotePrototype + :type enNote : EvernoteNotePrototype :rtype : (EvernoteAPIStatus, EvernoteNote) :returns Status and Note """ @@ -230,7 +228,7 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot tagNames = enNote.Tags if enNote.NotebookGuid: parentNotebook = enNote.NotebookGuid guid = enNote.Guid - + if resources is None: resources = [] callType = "create" validation_status = EvernoteAPIStatus.Uninitialized @@ -249,7 +247,7 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot callType = "update" ourNote.guid = guid - ## Build body of note + ## Build body of note nBody = self.makeNoteBody(noteContents, resources) if not validated is True and not validation_status.IsSuccess: success, errors = self.validateNoteBody(nBody, ourNote.title) @@ -279,10 +277,10 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot try: note = getattr(self.noteStore, callType + 'Note')(self.token, ourNote) except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return EvernoteAPIStatus.RateLimitError, None except socket.error, v: - if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return EvernoteAPIStatus.SocketError, None except EDAMUserException, edue: ## Something was wrong with the note data @@ -361,18 +359,16 @@ def update_ancillary_data(self): @staticmethod def report_ancillary_data_results(new_tags, new_nbs, title_prefix='', report_blank=False): - if new_tags is 0 and new_nbs is 0: - if not report_blank: return - strr = 'No new tags or notebooks found' + if new_tags is 0 and new_nbs is 0: + if not report_blank: return + strr = 'No new tags or notebooks found' elif new_tags is 0: strr = '%d new notebook%s found' % (new_nbs, '' if new_nbs is 1 else 's') elif new_nbs is 0: strr = '%d new tag%s found' % (new_tags, '' if new_tags is 1 else 's') else: strr = '%d new tag%s and %d new notebook%s found' % (new_tags, '' if new_tags is 1 else 's', new_nbs, '' if new_nbs is 1 else 's') - show_tooltip("%sUpdate of ancillary data complete: " % title_prefix + strr, do_log=True) - + show_tooltip("%sUpdate of ancillary data complete: " % title_prefix + strr, do_log=True) def set_notebook_data(self): if not hasattr(self, 'notebook_data') or not self.notebook_data or len(self.notebook_data.keys()) == 0: - self.notebook_data = {x['guid']: EvernoteNotebook(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS)} - + self.notebook_data = {x['guid']: EvernoteNotebook(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS)} def check_notebook_metadata(self, notes): """ :param notes: @@ -382,7 +378,7 @@ def check_notebook_metadata(self, notes): self.set_notebook_data() for note in notes: assert(isinstance(note, EvernoteNotePrototype)) - if note.NotebookGuid in self.notebook_data: continue + if note.NotebookGuid in self.notebook_data: continue new_nbs = self.update_notebooks_database() if note.NotebookGuid in self.notebook_data: log("Missing notebook GUID %s for note %s when checking notebook metadata. Notebook was found after updating Anknotes' notebook database." + '' if new_nbs < 1 else ' In total, %d new notebooks were found.' % new_nbs) @@ -410,14 +406,14 @@ def update_notebooks_database(self): self.initialize_note_store() api_action_str = u'trying to update Evernote notebooks.' log_api("listNotebooks") - try: + try: notebooks = self.noteStore.listNotebooks(self.token) """: type : list[evernote.edam.type.ttypes.Notebook] """ except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return None except socket.error, v: - if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return None data = [] self.notebook_data = {} @@ -435,33 +431,32 @@ def update_notebooks_database(self): data) db.commit() # log_dump(ankDB().all("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS), 'sql data', crosspost_to_default=False) - return len(self.notebook_data) - old_count - + return len(self.notebook_data) - old_count def update_tags_database(self, reason_str=''): if hasattr(self, 'LastTagDBUpdate') and datetime.now() - self.LastTagDBUpdate < timedelta(minutes=15): - return None + return None self.LastTagDBUpdate = datetime.now() self.initialize_note_store() api_action_str = u'trying to update Evernote tags.' - log_api("listTags" + (': ' + reason_str) if reason_str else '') + log_api("listTags" + (': ' + reason_str) if reason_str else '') try: tags = self.noteStore.listTags(self.token) """: type : list[evernote.edam.type.ttypes.Tag] """ except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str): raise + if not HandleEDAMRateLimitError(e, api_action_str): raise if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return None except socket.error, v: - if not HandleSocketError(v, api_action_str): raise + if not HandleSocketError(v, api_action_str): raise if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return None data = [] - self.tag_data = {} + self.tag_data = {} enTag = None for tag in tags: enTag = EvernoteTag(tag) self.tag_data[enTag.Guid] = enTag - data.append(enTag.items()) + data.append(enTag.items()) if not enTag: return None db=ankDB() old_count=db.scalar("SELECT COUNT(*) FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS) @@ -470,25 +465,23 @@ def update_tags_database(self, reason_str=''): ankDB().executemany(enTag.sqlUpdateQuery(), data) ankDB().commit() return len(self.tag_data) - old_count - + def set_tag_data(self): if not hasattr(self, 'tag_data') or not self.tag_data or len(self.tag_data.keys()) == 0: - self.tag_data = {x['guid']: EvernoteTag(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS)} - + self.tag_data = {x['guid']: EvernoteTag(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS)} def get_missing_tags(self, current_tags, from_guids=True): - if isinstance(current_tags, list): current_tags = set(current_tags) + if isinstance(current_tags, list): current_tags = set(current_tags) self.set_tag_data() all_tags = set(self.tag_data.keys() if from_guids else [v.Name for k, v in self.tag_data.items()]) - missing_tags = current_tags - all_tags + missing_tags = current_tags - all_tags if missing_tags: log_error("Missing Tag %s(s) were found:\nMissing: %s\n\nCurrent: %s\n\nAll Tags: %s\n\nTag Data: %s" % ('Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)), ', '.join(sorted(current_tags)), ', '.join(sorted(all_tags)), str(self.tag_data))) - return missing_tags - + return missing_tags def get_matching_tag_data(self, tag_guids=None, tag_names=None): tagGuids = [] tagNames = [] - assert tag_guids or tag_names - from_guids = True if (tag_guids is not None) else False + assert tag_guids or tag_names + from_guids = True if (tag_guids is not None) else False tags_original = tag_guids if from_guids else tag_names if self.get_missing_tags(tags_original, from_guids): self.update_tags_database("Missing Tag %s(s) Were found when attempting to get matching tag data" % ('Guids' if from_guids else 'Names')) @@ -507,8 +500,7 @@ def get_matching_tag_data(self, tag_guids=None, tag_names=None): tagGuids.append(k) tagNames.append(v.Name if is_struct else v) tagNames = sorted(tagNames, key=lambda s: s.lower()) - return tagGuids, tagNames - + return tagGuids, tagNames def check_tags_up_to_date(self): for evernote_guid in self.evernote_guids: if evernote_guid not in self.metadata: @@ -517,7 +509,7 @@ def check_tags_up_to_date(self): note_metadata = self.metadata[evernote_guid] if not note_metadata.tagGuids: continue for tag_guid in note_metadata.tagGuids: - if tag_guid in self.tag_data: continue + if tag_guid in self.tag_data: continue tag = EvernoteTag(fetch_guid=tag_guid) if not tag.success: return False self.tag_data[tag_guid] = tag diff --git a/anknotes/constants.py b/anknotes/constants.py index 59c2f32..5d4e56a 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -19,33 +19,33 @@ class FDN: ANKI_TITLE_MISMATCHES = ANKI_ORPHANS + 'AnkiTitleMismatches' ANKNOTES_TITLE_MISMATCHES = ANKI_ORPHANS + 'AnknotesTitleMismatches' ANKNOTES_ORPHANS = ANKI_ORPHANS + 'AnknotesOrphans' - ANKI_ORPHANS += 'AnkiOrphans' + ANKI_ORPHANS += 'AnkiOrphans' BASE_NAME = '' DEFAULT_NAME = 'anknotes' MAIN = DEFAULT_NAME ACTIVE = DEFAULT_NAME - USE_CALLER_NAME = False + USE_CALLER_NAME = False class ANCILLARY: TEMPLATE = os.path.join(FOLDERS.ANCILLARY, 'FrontTemplate.htm') CSS = u'_AviAnkiCSS.css' CSS_QMESSAGEBOX = os.path.join(FOLDERS.ANCILLARY, 'QMessageBox.css') ENML_DTD = os.path.join(FOLDERS.ANCILLARY, 'enml2.dtd') class SCRIPTS: - VALIDATION = os.path.join(FOLDERS.ADDONS, 'anknotes_start_note_validation.py') - FIND_DELETED_NOTES = os.path.join(FOLDERS.ADDONS, 'anknotes_start_find_deleted_notes.py') + VALIDATION = os.path.join(FOLDERS.ADDONS, 'anknotes_start_note_validation.py') + FIND_DELETED_NOTES = os.path.join(FOLDERS.ADDONS, 'anknotes_start_find_deleted_notes.py') class GRAPHICS: class ICON: - EVERNOTE_WEB = os.path.join(FOLDERS.GRAPHICS, u'evernote_web.ico') + EVERNOTE_WEB = os.path.join(FOLDERS.GRAPHICS, u'evernote_web.ico') EVERNOTE_ARTCORE = os.path.join(FOLDERS.GRAPHICS, u'evernote_artcore.ico') TOMATO = os.path.join(FOLDERS.GRAPHICS, u'Tomato-icon.ico') class IMAGE: - pass + pass IMAGE.EVERNOTE_WEB = ICON.EVERNOTE_WEB.replace('.ico', '.png') IMAGE.EVERNOTE_ARTCORE = ICON.EVERNOTE_ARTCORE.replace('.ico', '.png') class USER: TABLE_OF_CONTENTS_ENEX = os.path.join(FOLDERS.USER, "Table of Contents.enex") LAST_PROFILE_LOCATION = os.path.join(FOLDERS.USER, 'anki.profile') - + class ANKNOTES: DATE_FORMAT = '%Y-%m-%d %H:%M:%S' class DEVELOPER_MODE: @@ -57,9 +57,9 @@ class HIERARCHY: class MODELS: class TYPES: - CLOZE = 1 + CLOZE = 1 class OPTIONS: - IMPORT_STYLES = True + IMPORT_STYLES = True DEFAULT = 'evernote_note' REVERSIBLE = 'evernote_note_reversible' REVERSE_ONLY = 'evernote_note_reverse_only' @@ -71,7 +71,7 @@ class TEMPLATES: CLOZE = 'EvernoteReviewCloze' -class FIELDS: +class FIELDS: TITLE = 'Title' CONTENT = 'Content' SEE_ALSO = 'See_Also' @@ -87,7 +87,7 @@ class ORD: pass ORD.CONTENT = LIST.index(CONTENT) + 1 ORD.SEE_ALSO = LIST.index(SEE_ALSO) + 1 - + class DECKS: DEFAULT = "Evernote" TOC_SUFFIX = "::See Also::TOC" @@ -103,41 +103,41 @@ class TAGS: OUTLINE = '#Outline' OUTLINE_TESTABLE = '#Outline.Testable' REVERSIBLE = '#Reversible' - REVERSE_ONLY = '#Reversible_Only' - + REVERSE_ONLY = '#Reversible_Only' class EVERNOTE: class IMPORT: class PAGING: # Note that Evernote's API documentation says not to run API calls to findNoteMetadata with any less than a 15 minute interval # Auto Paging is probably only useful in the first 24 hours, when API usage is unlimited, or when executing a search that is likely to have most of the notes up-to-date locally - # To keep from overloading Evernote's servers, and flagging our API key, I recommend pausing 5-15 minutes in between searches, the higher the better. - class RESTART: + # To keep from overloading Evernote's servers, and flagging our API key, I recommend pausing 5-15 minutes in between searches, the higher the better. + class RESTART: DELAY_MINIMUM_API_CALLS = 10 - ENABLED = False + INTERVAL_OVERRIDE = 60*5 + ENABLED = False INTERVAL = 60 * 15 - RESTART.INTERVAL = INTERVAL * 2 + INTERVAL_SANDBOX = 60 * 5 + RESTART.INTERVAL = INTERVAL * 2 INTERVAL = PAGING.INTERVAL * 4 / 3 METADATA_RESULTS_LIMIT = 10000 API_CALLS_LIMIT = 300 class UPLOAD: ENABLED = True # Set False if debugging note creation MAX = -1 # Set to -1 for unlimited - RESTART_INTERVAL = 30 # In seconds + RESTART_INTERVAL = 30 # In seconds class VALIDATION: - ENABLED = True - AUTOMATED = True + ENABLED = True + AUTOMATED = True class API: class RateLimitErrorHandling: IgnoreError, ToolTipError, AlertError = range(3) CONSUMER_KEY = "holycrepe" - IS_SANDBOXED = False + IS_SANDBOXED = False EDAM_RATE_LIMIT_ERROR_HANDLING = RateLimitErrorHandling.ToolTipError - DEBUG_RAISE_ERRORS = False - + DEBUG_RAISE_ERRORS = False class TABLES: SEE_ALSO = "anknotes_see_also" NOTE_VALIDATION_QUEUE = "anknotes_note_validation_queue" - AUTO_TOC = u'anknotes_auto_toc' + AUTO_TOC = u'anknotes_auto_toc' class EVERNOTE: NOTEBOOKS = "anknotes_evernote_notebooks" TAGS = "anknotes_evernote_tags" @@ -170,20 +170,24 @@ class ACCOUNT: SHARD = 'ankNotesEvernoteAccountSHARD' UID_DEFAULT_VALUE = '0' SHARD_DEFAULT_VALUE = 'x999' - LAST_IMPORT = "ankNotesEvernoteLastAutoImport" + LAST_IMPORT = "ankNotesEvernoteLastAutoImport" PAGINATION_CURRENT_PAGE = 'anknotesEvernotePaginationCurrentPage' AUTO_PAGING = 'anknotesEvernoteAutoPaging' AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + EVERNOTE.API.CONSUMER_KEY + ( - "_SANDBOX" if EVERNOTE.API.IS_SANDBOXED else "") - class ANKI: + "_SANDBOX" if EVERNOTE.API.IS_SANDBOXED else "") + class ANKI: class DECKS: EVERNOTE_NOTEBOOK_INTEGRATION = 'anknotesUseNotebookNameForAnkiDeckName' - BASE = 'anknotesDefaultAnkiDeck' - BASE_DEFAULT_VALUE = DECKS.DEFAULT + BASE = 'anknotesDefaultAnkiDeck' + BASE_DEFAULT_VALUE = DECKS.DEFAULT class TAGS: - TO_DELETE = 'anknotesTagsToDelete' - KEEP_TAGS_DEFAULT_VALUE = True + TO_DELETE = 'anknotesTagsToDelete' + KEEP_TAGS_DEFAULT_VALUE = True KEEP_TAGS = 'anknotesTagsKeepEvernoteTags' DELETE_EVERNOTE_QUERY_TAGS = 'anknotesTagsDeleteEvernoteQueryTags' - UPDATE_EXISTING_NOTES = 'anknotesUpdateExistingNotes' + UPDATE_EXISTING_NOTES = 'anknotesUpdateExistingNotes' ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX = "ankNotesCheckableMenuItems" + + +# Allow user-defined options; place at end of document so that user-defined options override +from anknotes.constants_user import * diff --git a/anknotes/constants_user.py b/anknotes/constants_user.py new file mode 100644 index 0000000..246cb1d --- /dev/null +++ b/anknotes/constants_user.py @@ -0,0 +1,9 @@ +# INSTRUCTIONS: +# USE THIS FILE TO OVERRIDE THE MAIN SETTINGS FILE +# PREFIX ALL SETTINGS WITH THE constants MODULE REFERENCE AS SHOWN BELOW: +# DON'T FORGET TO REGENERATE ANY VARIABLES THAT DERIVE FROM THE ONES YOU ARE CHANGING + +from anknotes import constants +# constants.EVERNOTE.API.IS_SANDBOXED = True +# constants.SETTINGS.EVERNOTE.AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + constants.EVERNOTE.API.CONSUMER_KEY + ( + # "_SANDBOX" if constants.EVERNOTE.API.IS_SANDBOXED else "") diff --git a/anknotes/counters.py b/anknotes/counters.py index fa8ff4c..7e19433 100644 --- a/anknotes/counters.py +++ b/anknotes/counters.py @@ -1,5 +1,5 @@ import os -import sys +import sys from pprint import pprint from addict import Dict inAnki='anki' in sys.modules @@ -10,14 +10,155 @@ def print_banner(title): print "-" * max(40, len(title) + 5) +class DictCaseInsensitive(Dict): + def print_banner(self, title): + print self.make_banner(title) + + @staticmethod + def make_banner(title): + return '\n'.join(["-" * max(40, len(title) + 5), title ,"-" * max(40, len(title) + 5)]) + + def __process_kwarg__(self, kwargs, key, default=None, replace_none_type=True): + key = self.__key_transform__(key, kwargs.keys()) + if key not in kwargs: return default + val = kwargs[key] + if val is None and replace_none_type: val = default + del kwargs[key] + return val + def __key_transform__(self, key,keys=None): + if keys is None: keys = self.keys() + for k in keys: + if k.lower() == key.lower(): return k + return key + def __init__(self, *args, **kwargs): + # if not isinstance(label, unicode) and not isinstance(label, str): raise TypeError("Cannot create counter label from non-string type: " + str(label)) + # print "kwargs: %s" % (str(kwargs)) + lbl = self.__process_kwarg__(kwargs, 'label', 'root') + parent_lbl = self.__process_kwarg__(kwargs, 'parent_label', '') + # print "lbl: %s\nkwargs: %s" % (lbl, str(kwargs)) + self.__label__ = "root" + self.__parent_label__ = "" + return super(DictCaseInsensitive, self).__init__(*args, **kwargs) + + def reset(self, keys_to_keep=None): + if keys_to_keep is None: keys_to_keep=self.__my_aggregates__.lower().split("|") + for key in self.keys(): + if key.lower() not in keys_to_keep: del self[key] + + __label__ = '' + __parent_label__ = '' + __my_aggregates__ = '' + __my_attrs__ = '__label__|__parent_label__|__my_aggregates__' + @property + def label(self): return self.__label__ + + @property + def parent_label(self): return self.__parent_label__ + + @property + def full_label(self): return self.parent_label + ('.' if self.parent_label else '') + self.label + + def __setattr__(self, key, value): + key_adj = self.__key_transform__(key) + if key[0:1] + key[-1:] == '__': + if key.lower() not in self.__my_attrs__.lower().split('|'): + raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) + else: super(Dict, self).__setattr__(key, value) + # elif key == 'Count': + # self.setCount(value) + # # super(CaseInsensitiveDict, self).__setattr__(key, value) + # setattr(self, 'Count', value) + elif (hasattr(self, key)): + # print "Setting key " + key + ' value... to ' + str(value) + self[key_adj] = value + else: + print "Setting attr %s to type %s value %s" % (key_adj, type(value), value) + super(Dict, self).__setitem__(key_adj, value) + + def __setitem__(self, name, value): + # print "Setting item %s to type %s value %s" % (name, type(value), value) + super(Dict, self).__setitem__(name, value) + + def __getitem__(self, key): + adjkey = self.__key_transform__(key) + if adjkey not in self: + if key[0:1] + key[-1:] == '__': + if key.lower() not in self.__my_attrs__.lower().split('|'): + try: + return super(Dict, self).__getattr__(key.lower()) + except: + raise(KeyError("Could not find protected item " + key)) + return super(DictCaseInsensitive, self).__getattr__(key.lower()) + # print "Creating missing item: " + self.parent_label + ('.' if self.parent_label else '') + self.label + ' -> ' + repr(adjkey) + self[adjkey] = DictCaseInsensitive() + self[adjkey].__label__ = adjkey + self[adjkey].__parent_label__ = self.full_label + try: + return super(DictCaseInsensitive, self).__getitem__(adjkey) + except TypeError: + return "<null>" + +class DictCaseInsensitive2(Dict): + __label__ = '' + __parent_label__ = '' + __my_aggregates__ = '' + __my_attrs__ = '__label__|__parent_label__' + def __process_kwarg__(self, kwargs, key, default=None): + key = self.__key_transform__(key, kwargs.keys()) + if key not in kwargs: return default + val = kwargs[key] + del kwargs[key] + return val + def __key_transform__(self, key,keys=None): + if keys is None: keys = self.keys() + for k in keys: + if k.lower() == key.lower(): return k + return key + def __init__(self, *args, **kwargs): + print "kwargs: %s" % (str(kwargs)) + lbl = self.__process_kwarg__(kwargs, 'label', 'root') + parent_lbl = self.__process_kwarg__(kwargs, 'parent_label', '') + print "lbl: %s\nkwargs: %s" % (lbl, str(kwargs)) + if not isinstance(lbl, unicode) and not isinstance(lbl, str): raise TypeError("Cannot create DictCaseInsensitive label from non-string type: " + str(lbl)) + if not isinstance(parent_lbl, unicode) and not isinstance(parent_lbl, str): raise TypeError("Cannot create DictCaseInsensitive parent label from non-string type: " + str(parent_lbl)) + self.__label__ = lbl + self.__parent_label__ = parent_lbl + # return super(DictCaseInsensitive, self).__init__(*args, **kwargs) + + + def __setattr__(self, key, value): + key_adj = self.__key_transform__(key) + if key[0:1] + key[-1:] == '__': + if key.lower() not in self.__my_attrs__.lower().split('|'): + raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) + super(Dict, self).__setattr__(key, value) + else: + print "Setting attr %s to type %s value %s" % (key_adj, type(value), value) + super(Dict, self).__setitem__(key_adj, value) + + def __setitem__(self, name, value): + super(Dict, self).__setitem__(name, value) + + def __getitem__(self, key): + adjkey = self.__key_transform__(key) + if adjkey not in self: + if key[0:1] + key[-1:] == '__': + if key.lower() not in self.__my_attrs__.lower().split('|'): + try: return super(Dict, self).__getattr__(key.lower()) + except: raise(KeyError("Could not find protected item " + key)) + return super(Dict, self).__getattr__(key.lower()) + self[adjkey] = DictCaseInsensitive(label=adjkey,parent_label=self.full_label) + try: return super(Dict, self).__getitem__(adjkey) + except TypeError: return "<null>" + class Counter(Dict): def print_banner(self, title): print self.make_banner(title) - + @staticmethod def make_banner(title): return '\n'.join(["-" * max(40, len(title) + 5), title ,"-" * max(40, len(title) + 5)]) - + def __init__(self, *args, **kwargs): self.setCount(0) # if not isinstance(label, unicode) and not isinstance(label, str): raise TypeError("Cannot create counter label from non-string type: " + str(label)) @@ -30,7 +171,7 @@ def reset(self, keys_to_keep=None): if keys_to_keep is None: keys_to_keep=self.__my_aggregates__.lower().split("|") for key in self.keys(): if key.lower() not in keys_to_keep: del self[key] - + def __key_transform__(self, key): for k in self.keys(): if k.lower() == key.lower(): return k @@ -42,7 +183,7 @@ def __key_transform__(self, key): __parent_label__ = '' __is_exclusive_sum__ = False __my_aggregates__ = 'max|max_allowed' - __my_attrs__ = '__count__|__is_exclusive_sum__|__label__|__parent_label__|__my_aggregates__' + __my_attrs__ = '__count__|__is_exclusive_sum__|__label__|__parent_label__|__my_aggregates__' def getCount(self): if self.__is_exclusive_sum__: return self.sum return self.__count__ @@ -71,7 +212,7 @@ def sum(self): # self.print_banner("Getting main Count ") sum = 0 for key in self.iterkeys(): - if key in self.__my_aggregates__.split("|"): continue + if key in self.__my_aggregates__.split("|"): continue val = self[key] if isinstance(val, int): sum += val @@ -81,7 +222,7 @@ def sum(self): return sum def increment(self, y=1, negate=False): - newCount = self.__sub__(y) if negate else self.__add__(y) + newCount = self.__sub__(y) if negate else self.__add__(y) # print "Incrementing %s by %d to %d" % (self.full_label, y, newCount) self.setCount(newCount) return newCount @@ -89,47 +230,45 @@ def increment(self, y=1, negate=False): step = increment def __coerce__(self, y): return (self.getCount(), y) - + def __div__(self, y): return self.getCount() / y - + def __rdiv__(self, y): return y / self.getCount() - + __truediv__ = __div__ - + def __mul__(self, y): return y * self.getCount() __rmul__ = __mul__ - + def __sub__(self, y): - return self.getCount() - y + return self.getCount() - y # return self.__add__(y, negate=True) - + def __add__(self, y, negate=False): # if isinstance(y, Counter): # print "y=getCount: %s" % str(y) # y = y.getCount() - return self.getCount() + y + return self.getCount() + y # * (-1 if negate else 1) - + __radd__ = __add__ - + def __rsub__(self, y, negate=False): return y - self.getCount() - + def __iadd__(self, y): self.increment(y) - + def __isub__(self, y): - self.increment(y, negate=True) - + self.increment(y, negate=True) def __truth__(self): print "truth" - return True - + return True def __bool__(self): return self.getCount() > 0 - + __nonzero__ = __bool__ def __setattr__(self, key, value): @@ -152,29 +291,27 @@ def __setattr__(self, key, value): def __setitem__(self, name, value): # print "Setting item %s to type %s value %s" % (name, type(value), value) super(Dict, self).__setitem__(name, value) - + def __get_summary__(self,level=1,header_only=False): keys=self.keys() counts=[Dict(level=level,label=self.label,full_label=self.full_label,value=self.getCount(),is_exclusive_sum=self.__is_exclusive_sum__,class_name=self.__class__.__name__,children=keys)] - if header_only: return counts + if header_only: return counts for key in keys: # print "Summaryzing key %s: %s " % (key, type( self[key])) if key not in self.__my_aggregates__.split("|"): counts += self[key].__get_summary__(level+1) - return counts - + return counts def __summarize_lines__(self, summary,header=True): lines=[] for i, item in enumerate(summary): exclusive_sum_marker = '*' if item.is_exclusive_sum and len(item.children) > 0 else ' ' - if i is 0 and header: + if i is 0 and header: lines.append("<%s%s:%s:%d>" % (exclusive_sum_marker.strip(), item.class_name, item.full_label, item.value)) - continue + continue # strr = '%s%d' % (exclusive_sum_marker, item.value) strr = (' ' * (item.level * 2 - 1) + exclusive_sum_marker + item.label + ':').ljust(16+item.level*2) lines.append(strr+' ' + str(item.value).rjust(3) + exclusive_sum_marker) - return '\n'.join(lines) - + return '\n'.join(lines) def __repr__(self): return self.__summarize_lines__(self.__get_summary__()) @@ -183,7 +320,7 @@ def __getitem__(self, key): if key == 'Count': return self.getCount() if adjkey not in self: if key[0:1] + key[-1:] == '__': - if key.lower() not in self.__my_attrs__.lower().split('|'): + if key.lower() not in self.__my_attrs__.lower().split('|'): try: return super(Dict, self).__getattr__(key.lower()) except: @@ -193,7 +330,7 @@ def __getitem__(self, key): self[adjkey] = Counter(adjkey) self[adjkey].__label__ = adjkey self[adjkey].__parent_label__ = self.full_label - self[adjkey].__is_exclusive_sum__ = True + self[adjkey].__is_exclusive_sum__ = True try: return super(Counter, self).__getitem__(adjkey) except TypeError: @@ -201,9 +338,7 @@ def __getitem__(self, key): # print "Unexpected type of self in __getitem__: " + str(type(self)) # raise TypeError # except: - # raise - - + # raise class EvernoteCounter(Counter): @property def success(self): @@ -219,8 +354,7 @@ def completed(self): @property def delayed(self): - return self.skipped + self.queued - + return self.skipped + self.queued @property def total(self): return self.getCount() #- self.max - self.max_allowed @@ -234,18 +368,18 @@ def aggregateSummary(self, includeHeader=True): is_exclusive_sum = key_code[0] is not '!' if not is_exclusive_sum: key_code = key_code[1:] key = key_code.lstrip('+') - level = len(key_code) - len(key) + 1 + level = len(key_code) - len(key) + 1 val = self.__getattr__(key) cls = type(val) if cls is not int: val = val.getCount() parent_lbl = '.'.join(parents) - full_label = parent_lbl + ('.' if parent_lbl else '') + key + full_label = parent_lbl + ('.' if parent_lbl else '') + key counts+=[Dict(level=level,label=key,full_label=full_label,value=val,is_exclusive_sum=is_exclusive_sum,class_name=cls,children=['<aggregate>'])] if level < last_level: del parents[-1] elif level > last_level: parents.append(key) - last_level = level + last_level = level return self.__summarize_lines__(counts,includeHeader) - + def fullSummary(self, title='Evernote Counter'): return '\n'.join( [self.make_banner(title + ": Summary"), @@ -254,23 +388,23 @@ def fullSummary(self, title='Evernote Counter'): self.make_banner(title + ": Aggregates"), self.aggregateSummary(False)] ) - + def __getattr__(self, key): - if hasattr(self, key) and key not in self.keys(): + if hasattr(self, key) and key not in self.keys(): return getattr(self, key) return super(EvernoteCounter, self).__getattr__(key) - + def __getitem__(self, key): # print 'getitem: ' + key return super(EvernoteCounter, self).__getitem__(key) - + from pprint import pprint -def test(): +def test(): global Counts absolutely_unused_variable = os.system("cls") - del absolutely_unused_variable - Counts = EvernoteCounter() + del absolutely_unused_variable + Counts = EvernoteCounter() Counts.unhandled.step(5) Counts.skipped.step(3) Counts.error.step() @@ -286,9 +420,9 @@ def test(): print (Counts) Counts.print_banner("Evernote Counter: Aggregates") print (Counts.aggregateSummary()) - + Counts.reset() - + print Counts.fullSummary('Reset Counter') return @@ -305,7 +439,7 @@ def test(): Counts.print_banner("Evernote Counter") # print Counts -if not inAnki and 'anknotes' not in sys.modules: test() +# if not inAnki and 'anknotes' not in sys.modules: test() diff --git a/anknotes/db.py b/anknotes/db.py index 161f620..f3618ec 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -20,10 +20,10 @@ def anki_profile_path_root(): def last_anki_profile_name(): root = anki_profile_path_root() name = ANKI.PROFILE_NAME - if name and os.path.isdir(os.path.join(root, name)): return name - if os.path.isfile(FILES.USER.LAST_PROFILE_LOCATION): + if name and os.path.isdir(os.path.join(root, name)): return name + if os.path.isfile(FILES.USER.LAST_PROFILE_LOCATION): name = file(FILES.USER.LAST_PROFILE_LOCATION, 'r').read().strip() - if name and os.path.isdir(os.path.join(root, name)): return name + if name and os.path.isdir(os.path.join(root, name)): return name dirs = [x for x in os.listdir(root) if os.path.isdir(os.path.join(root, x)) and x is not 'addons'] if not dirs: return "" return dirs[0] @@ -70,7 +70,7 @@ class ank_DB(object): def __init__(self, path=None, text=None, timeout=0): encpath = path if isinstance(encpath, unicode): - encpath = path.encode("utf-8") + encpath = path.encode("utf-8") if path: self._db = sqlite.connect(encpath, timeout=timeout) self._db.row_factory = sqlite.Row @@ -80,7 +80,7 @@ def __init__(self, path=None, text=None, timeout=0): else: self._db = mw.col.db._db self._path = mw.col.db._path - self._db.row_factory = sqlite.Row + self._db.row_factory = sqlite.Row self.echo = os.environ.get("DBECHO") self.mod = False @@ -190,12 +190,12 @@ def InitSeeAlso(self, forceRebuild=False): if_exists = "" self.execute( """CREATE TABLE %s `%s` ( `id` INTEGER, `source_evernote_guid` TEXT NOT NULL, `number` INTEGER NOT NULL DEFAULT 100, `uid` INTEGER NOT NULL DEFAULT -1, `shard` TEXT NOT NULL DEFAULT -1, `target_evernote_guid` TEXT NOT NULL, `html` TEXT NOT NULL, `title` TEXT NOT NULL, `from_toc` INTEGER DEFAULT 0, `is_toc` INTEGER DEFAULT 0, `is_outline` INTEGER DEFAULT 0, PRIMARY KEY(id) );""" % (if_exists, TABLES.SEE_ALSO)) - + def Init(self): self.execute( """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL UNIQUE, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL, PRIMARY KEY(guid) );""" % TABLES.EVERNOTE.NOTES) self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL)""" % TABLES.EVERNOTE.NOTES_HISTORY) + """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL)""" % TABLES.EVERNOTE.NOTES_HISTORY) self.execute( """CREATE TABLE IF NOT EXISTS `%s` ( `root_title` TEXT NOT NULL UNIQUE, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL, `notebookGuid` TEXT NOT NULL, PRIMARY KEY(root_title) );""" % TABLES.AUTO_TOC) self.execute( diff --git a/anknotes/detect_see_also_changes.py b/anknotes/detect_see_also_changes.py index 7545ce6..9893a2a 100644 --- a/anknotes/detect_see_also_changes.py +++ b/anknotes/detect_see_also_changes.py @@ -33,22 +33,22 @@ class pstrings: """: type : notes.version.see_also_match """ __regex_original__ = None """: type : notes.version.see_also_match """ - - @property + + @property def regex_original(self): if self.original is None: return None if self.__regex_original__ is None: self.__regex_original__ = notes.version.see_also_match(self.original) return self.__regex_original__ - - @property + + @property def regex_processed(self): if self.processed is None: return None if self.__regex_processed__ is None: self.__regex_processed__ = notes.version.see_also_match(self.processed) return self.__regex_processed__ - - @property + + @property def regex_updated(self): if self.updated is None: return None if self.__regex_updated__ is None: @@ -74,11 +74,11 @@ def updated(self, value): @property def final(self): return str_process_full(self.updated) - + @property def original(self): return self.__original__ - + def useProcessed(self): self.updated = self.processed @@ -146,7 +146,7 @@ def __init__(self, content=None): self.__content__ = content self.__match_attempted__ = 0 self.__matchobject__ = None - """:type : anknotes._re.__Match """ + """:type : anknotes._re.__Match """ content = pstrings() see_also = pstrings() old = version() @@ -168,10 +168,10 @@ def str_process(strr): def str_process_full(strr): return clean_evernote_css(strr) - + def main(evernote=None, anki=None): # @clockit - def print_results(log_folder='Diff\\SeeAlso',full=False, final=False): + def print_results(log_folder='Diff\\SeeAlso',full=False, final=False): if final: oldResults=n.old.content.final newResults=n.new.content.final @@ -193,10 +193,10 @@ def print_results(log_folder='Diff\\SeeAlso',full=False, final=False): # @clockit def process_note(): n.old.content = notes.version.pstrings(enNote.Content) - if not n.old.content.regex_original.successful_match: - if n.new.see_also.original == "": + if not n.old.content.regex_original.successful_match: + if n.new.see_also.original == "": n.new.content = notes.version.pstrings(n.old.content.original) - return False + return False n.new.content = notes.version.pstrings(n.old.content.original.replace('</en-note>', '<div><span><br/></span></div>' + n.new.see_also.original + '\n</en-note>')) n.new.see_also.updated = str_process(n.new.content.original) n.old.see_also.updated = str_process(n.old.content.original) @@ -204,18 +204,18 @@ def process_note(): n.match_type = 'V1' else: n.old.see_also = notes.version.pstrings(n.old.content.regex_original.main) - n.match_type = 'V2' + n.match_type = 'V2' if n.old.see_also.regex_processed.successful_match: assert True or str_process(n.old.content.regex_original.main) is n.old.content.regex_processed.main n.old.content.updated = n.old.content.original.replace(n.old.content.regex_original.main, str_process(n.old.content.regex_original.main)) n.old.see_also.useProcessed() - n.match_type += 'V3' + n.match_type += 'V3' n.new.see_also.regex_original.subject = n.new.see_also.original + '</en-note>' if not n.new.see_also.regex_original.successful_match: log.plain(enNote.Guid + '\n' + ', '.join(enNote.TagNames) + '\n' + n.new.see_also.original.content, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) - see_also_replace_old = n.old.content.original.match.processed.see_also.processed.content + see_also_replace_old = n.old.content.original.match.processed.see_also.processed.content n.old.see_also.updated = n.old.content.regex_updated.see_also - n.new.see_also.updated = n.new.see_also.processed + n.new.see_also.updated = n.new.see_also.processed n.match_type + 'V4' else: assert (n.old.content.regex_processed.see_also_content == notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content) @@ -232,7 +232,7 @@ def process_note(): # count_queued = 0 tmr = stopwatch.Timer(len(results), 25, 'Updating See Also Notes', label='SeeAlso-Step7', display_initial_info=False) log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), do_print=True) - log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), tmr.label) + log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), tmr.label) notes_updated=[] # number_updated = 0 for result in results: @@ -241,7 +241,7 @@ def process_note(): if tmr.step(): log.go("Note %5s: %s: %s" % ('#' + str(tmr.count), tmr.progress, enNote.FullTitle if enNote.Status.IsSuccess else '(%s)' % enNote.Guid), do_print=True, print_timestamp=False) flds = ankDB().scalar("SELECT flds FROM notes WHERE flds LIKE '%%%s%s%%'" % (FIELDS.EVERNOTE_GUID_PREFIX, enNote.Guid)).split("\x1f") - n.new.see_also = notes.version.pstrings(flds[FIELDS.ORD.SEE_ALSO]) + n.new.see_also = notes.version.pstrings(flds[FIELDS.ORD.SEE_ALSO]) result = process_note() if result is False: log.go('No match for %s' % enNote.FullTitle, tmr.label + '-NoUpdate') @@ -249,17 +249,17 @@ def process_note(): print_results('NoMatch\\Contents', full=True) continue if n.match_type != 'V1' and str_process(n.old.see_also.updated) == n.new.see_also.updated: - log.go('Match but contents are the same for %s' % enNote.FullTitle, tmr.label + '-NoUpdate') + log.go('Match but contents are the same for %s' % enNote.FullTitle, tmr.label + '-NoUpdate') print_results('Same\\SeeAlso') print_results('Same\\Contents', full=True) continue print_results() - print_results('Diff\\Contents', final=True) - enNote.Content = n.new.content.final + print_results('Diff\\Contents', final=True) + enNote.Content = n.new.content.final if not evernote: evernote = Evernote() whole_note = tmr.autoStep(evernote.makeNote(enNote=enNote), enNote.FullTitle, True) if tmr.reportStatus(status) == False: raise ValueError - if tmr.status.IsDelayableError: break + if tmr.status.IsDelayableError: break if tmr.status.IsSuccess: notes_updated.append(EvernoteNotePrototype(whole_note=whole_note)) if tmr.is_success and not anki: anki = Anki() tmr.Report(0, anki.update_evernote_notes(notes_updated) if tmr.is_success else 0) @@ -268,5 +268,5 @@ def process_note(): # if tmr.count_queued > 0: log.go(" > %4d queued for validation" % tmr.count_queued, tmr.label, do_print=True) # if tmr.count_error > 0: log.go(" > %4d error(s) occurred" % tmr.count_error, tmr.label, do_print=True) - + ## HOCM/MVP \ No newline at end of file diff --git a/anknotes/error.py b/anknotes/error.py index ff89e0b..6445d45 100644 --- a/anknotes/error.py +++ b/anknotes/error.py @@ -16,7 +16,7 @@ def HandleSocketError(e, strErrorBase): } if errorcode not in errno.errorcode: log_error("Unknown socket error (%s) occurred: %s" % (str(errorcode), str(e))) - return False + return False error_constant = errno.errorcode[errorcode] if errorcode in friendly_error_msgs: strError = friendly_error_msgs[errorcode] @@ -51,4 +51,4 @@ def HandleEDAMRateLimitError(e, strError): showInfo(strError) elif EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.ToolTipError: show_tooltip(strError) - return True + return True \ No newline at end of file diff --git a/anknotes/extra/ancillary/FrontTemplate.htm b/anknotes/extra/ancillary/FrontTemplate.htm index 25725df..5ef9feb 100644 --- a/anknotes/extra/ancillary/FrontTemplate.htm +++ b/anknotes/extra/ancillary/FrontTemplate.htm @@ -15,7 +15,7 @@ <h2>ANKNOTES</h2> {{#%(See Also)s}} <div id='Header-Links'> <span class='Field-%(See Also)s'> -<a href='javascript:;' onclick='scrollToElementVisible("Field-%(See Also)s")' class='header'>See Also</a>: +<a href='javascript:;' onclick='scrollToElementVisible("Field-%(See Also)s")' class='header'>See Also</a>: </span> {{#%(TOC)s}} <span class='Field-%(TOC)s'> @@ -92,13 +92,13 @@ <h2>ANKNOTES</h2> function generateEvernoteLink(guid_field) { guid = guid_field.replace(evernote_guid_prefix, '') en_link = 'evernote://view/'+evernote_uid+'/'+evernote_shard+'/'+guid+'/'+guid+'/' - return en_link + return en_link } function setElementDisplay(id,show) { el = document.getElementById(id) if (el == null) { return; } // Assuming if display is not set, it is set to none by CSS - if (show === 0) { show = (el.style.display == 'none' || el.style.display == ''); } + if (show === 0) { show = (el.style.display == 'none' || el.style.display == ''); } el.style.display = (show ? 'block' : 'none') } function hideElement(id) { @@ -110,22 +110,19 @@ <h2>ANKNOTES</h2> function toggleElement(id) { setElementDisplay(id, 0); } - + function scrollToElement(id, show) { setElementDisplay(id, show); el = document.getElementById(id) if (el == null) { return; } window.scroll(0,findPos(el)); - } - + } function scrollToElementToggle(id) { scrollToElement(id, 0); - } - + } function scrollToElementVisible(id) { scrollToElement(id, true); - } - + } //Finds y value of given object function findPos(obj) { var curtop = 0; diff --git a/anknotes/extra/ancillary/QMessageBox.css b/anknotes/extra/ancillary/QMessageBox.css index 0bbb0f8..2a37c1f 100644 --- a/anknotes/extra/ancillary/QMessageBox.css +++ b/anknotes/extra/ancillary/QMessageBox.css @@ -10,6 +10,6 @@ a { color: rgb(105, 170, 53); font-weight:bold; } a:hover { color: rgb(135, 187, 93); font-weight:bold; text-decoration: none; } a:active { color: rgb(135, 187, 93); font-weight:bold; text-decoration: none; } table a { color: rgb(106, 0, 129);} -td.td1 { font-weight: bold; color: #bf0060; text-align: center; padding-left: 10px; padding-right: 10px; } -td.td2 { text-transform: uppercase; font-weight: bold; color: #0060bf; padding-left:20px; padding-right:20px; font-size: 18px; } -td.td3 { color: #666; font-size: 10px; } +td.td1 { font-weight: bold; color: #bf0060; text-align: center; padding-left: 10px; padding-right: 10px; } +td.td2 { text-transform: uppercase; font-weight: bold; color: #0060bf; padding-left:20px; padding-right:20px; font-size: 18px; } +td.td3 { color: #666; font-size: 10px; } diff --git a/anknotes/extra/ancillary/_AviAnkiCSS.css b/anknotes/extra/ancillary/_AviAnkiCSS.css index 535968c..ee7e910 100644 --- a/anknotes/extra/ancillary/_AviAnkiCSS.css +++ b/anknotes/extra/ancillary/_AviAnkiCSS.css @@ -1,7 +1,7 @@ /* @import url("_AviAnkiCSS.css"); */ @import url(https://fonts.googleapis.com/css?family=Roboto:400,300,700,100); -/******************************************************************************************************* +/******************************************************************************************************* Default Card Rules *******************************************************************************************************/ @@ -19,7 +19,7 @@ body { color: black; } -/******************************************************************************************************* +/******************************************************************************************************* Header rectangles, which sit at the top of every single card *******************************************************************************************************/ @@ -38,7 +38,7 @@ section.header { section.header div { padding: 1em; - margin: 0px; + margin: 0px; position: relative; top: 50%; -webkit-transform: translateY(-50%); @@ -55,7 +55,7 @@ section.header { /* border:3px #990000 solid; */ } -/******************************************************************************************************* +/******************************************************************************************************* ANKNOTES Top-Right call out *******************************************************************************************************/ @@ -76,16 +76,16 @@ section.header.header-avi h2, section.header.header-bluewhitered h2 { color: rgb(138, 16, 16); } -/******************************************************************************************************* +/******************************************************************************************************* TITLE Fields *******************************************************************************************************/ -.card section.header.Field-Title, .card section.header.Field-Title-Prompt { +.card section.header.Field-Title, .card section.header.Field-Title-Prompt { text-align: center; font-family: Tahoma; font-weight: bold; font-size: 72px; - font-variant: small-caps; + font-variant: small-caps; padding:10px; /* color: #A40F2D; */ } @@ -110,10 +110,10 @@ color: rgb(138, 16, 16); color: rgb(210, 13, 13); } -/******************************************************************************************************* +/******************************************************************************************************* Header bars with custom gradient backgrounds *******************************************************************************************************/ - + section.header { text-shadow: 1px 1px 2px rgba(0,0,0,0.2); @@ -146,22 +146,22 @@ section.header.header-redorange { border:3px #990000 solid; } -section.header.header-bluewhitered, +section.header.header-bluewhitered, section.header.header-avi { color:#004C99; } -a:hover section.header.header-bluewhitered, +a:hover section.header.header-bluewhitered, a:hover section.header.header-avi { color: rgb(10, 121, 243); } -.card section.header.header-bluewhitered.Field-Title span.link, +.card section.header.header-bluewhitered.Field-Title span.link, .card section.header.header-avi.Field-Title span.link { border-bottom-color:#004C99; } -section.header.header-bluewhitered, +section.header.header-bluewhitered, section.header.header-avi { /* Background gradient code */ background: #3b679e; /* Old browsers */ @@ -198,14 +198,14 @@ section.header.header-avi-bluered { background: -o-linear-gradient(left, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* Opera 11.10+ */ background: -ms-linear-gradient(left, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* IE10+ */ background: linear-gradient(to right, #bf0060 0%,#0060bf 36%,#0060bf 58%,#bf0060 100%); /* W3C */ - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#0060bf', endColorstr='#bf0060',GradientType=1 ); /* IE6-9 */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#0060bf', endColorstr='#bf0060',GradientType=1 ); /* IE6-9 */ /* z-index: 100; /* the stack order: foreground */ /* border-bottom:1px #004C99 solid; */ border:3px #004C99 solid; } -/******************************************************************************************************* - Headers with Links for See Also, TOC, Outline +/******************************************************************************************************* + Headers with Links for See Also, TOC, Outline *******************************************************************************************************/ .card #Header-Links { font-size: 14px; @@ -218,8 +218,8 @@ section.header.header-avi-bluered { color: rgb(45, 79, 201); } -/******************************************************************************************************* - HTML Link Elements +/******************************************************************************************************* + HTML Link Elements *******************************************************************************************************/ a { @@ -250,12 +250,10 @@ a:hover { .card .Field-Outline a, .card #Field-Header-Links #Field-Outline-Link a .Note_Link, { color: rgb(105, 170, 53); -} - +} .card .Field-Outline a:hover , .card #Field-Header-Links #Field-Outline-Link a:hover .Note_Link{ color: rgb(135, 187, 93); -} - +} .card #Link-EN-Self a { color: rgb(30, 155, 67) } @@ -264,8 +262,8 @@ a:hover { color: rgb(107, 226, 143) } -/******************************************************************************************************* - TOC/Outline Headers (Automatically generated and placed in TOC/Outline fields when > 1 source note) +/******************************************************************************************************* + TOC/Outline Headers (Automatically generated and placed in TOC/Outline fields when > 1 source note) *******************************************************************************************************/ .card .TOC, .card .Outline { @@ -281,7 +279,7 @@ a:hover { } .card .TOC .header, .card .Outline .header { - text-decoration: underline; + text-decoration: underline; color: #bf0060; } @@ -295,7 +293,7 @@ a:hover { -/******************************************************************************************************* +/******************************************************************************************************* Per-Field Rules *******************************************************************************************************/ @@ -320,7 +318,7 @@ a:hover { margin-bottom: 10px; } -/******************************************************************************************************* +/******************************************************************************************************* Extra Field/Tags Rules *******************************************************************************************************/ @@ -328,11 +326,10 @@ a:hover { .card #Tags { color: #aaa; } - + .card #Field-Extra .header, #Field-Extra-Front .header { color: #666; -} - +} .card #Field-Extra .header, #Field-Extra-Front .header , #Tags .header { font-weight: bold; } @@ -341,15 +338,15 @@ a:hover { color: #444; } -/******************************************************************************************************* +/******************************************************************************************************* Special Span Classes *******************************************************************************************************/ .card .occluded { color: #555; } -.card div.occluded { - display: inline-block; +.card div.occluded { + display: inline-block; margin: 0px; padding: 0px; } .card div.occluded :first-child { @@ -364,18 +361,18 @@ a:hover { font-weight: bold; } -/******************************************************************************************************* +/******************************************************************************************************* Default Visibility Rules *******************************************************************************************************/ .card #Field-Cloze-Content, -.card section.header.Field-Title-Prompt, -.card #Side-Front #Footer-Line, +.card section.header.Field-Title-Prompt, +.card #Side-Front #Footer-Line, .card #Side-Front #Field-See_Also, .card #Field-TOC, .card #Field-Outline, -.card #Side-Front #Field-Extra, +.card #Side-Front #Field-Extra, .card #Side-Back #Field-Extra-Front, .card #Card-EvernoteReviewCloze #Field-Content, .card #Card-EvernoteReviewReversed #Side-Front section.header.Field-Title @@ -385,19 +382,19 @@ a:hover { .card #Side-Front #Header-Links, .card #Card-EvernoteReview #Side-Front #Field-Content, -.card #Side-Front .occluded +.card #Side-Front .occluded { visibility: hidden; } - -.card #Card-EvernoteReviewCloze #Field-Cloze-Content, + +.card #Card-EvernoteReviewCloze #Field-Cloze-Content, .card #Card-EvernoteReviewReversed #Side-Front section.header.Field-Title-Prompt, .card #Side-Back #Field-See_Also { display: block; } -/******************************************************************************************************* +/******************************************************************************************************* Rules for Anki-Generated Classes *******************************************************************************************************/ diff --git a/anknotes/extra/ancillary/_attributes.css b/anknotes/extra/ancillary/_attributes.css index bae4839..cbdc860 100644 --- a/anknotes/extra/ancillary/_attributes.css +++ b/anknotes/extra/ancillary/_attributes.css @@ -1,11 +1,11 @@ -/******************************************************************************************************* +/******************************************************************************************************* Helpful Attributes *******************************************************************************************************/ /* - Colors: - <OL> + Colors: + <OL> Levels 'OL': { 1: { @@ -44,7 +44,7 @@ Auto TOC: color: rgb(11, 59, 225); Modifiers - Orange: + Orange: color: rgb(222, 87, 0); Orange (Light): color: rgb(250, 122, 0); @@ -52,7 +52,7 @@ color: rgb(164, 15, 45); Pink Alternative LVL1: color: rgb(188, 0, 88); - + Header Boxes Red-Orange: Gradient Start: @@ -62,7 +62,7 @@ Title: color: rgb(153, 0, 0); color: rgb(106, 6, 6); - Blue-White-Red + Blue-White-Red Gradient Start: color: rgb(59, 103, 158); Gradient End: @@ -77,7 +77,7 @@ color: rgb(0, 96, 191); Gradient End: color: rgb(191, 0, 96); - Title: + Title: color: rgb(128, 166, 204); color: rgb(241, 135, 154); Old Border: @@ -88,27 +88,26 @@ Titles: Field Title Prompt: color: rgb(169, 0, 48); - + See Also (Link + Hover) See Also: color: rgb(45, 79, 201); color: rgb(108, 132, 217); - TOC: + TOC: color: rgb(173, 0, 0); color: rgb(196, 71, 71); - Outline: + Outline: color: rgb(105, 170, 53); color: rgb(135, 187, 93); - + Evernote Anknotes Self-Referential Link color: rgb(30, 155, 67) color: rgb(107, 226, 143) - + Evernote Classic (In-App) Note Link color: rgb(105, 170, 53); color: rgb(135, 187, 93); - + Unused: - color: rgb(122, 220, 241); - + color: rgb(122, 220, 241); */ \ No newline at end of file diff --git a/anknotes/extra/ancillary/regex.txt b/anknotes/extra/ancillary/regex.txt index e3f878e..c22e372 100644 --- a/anknotes/extra/ancillary/regex.txt +++ b/anknotes/extra/ancillary/regex.txt @@ -5,8 +5,8 @@ Converting this file to Python: Finding Evernote Links <a href="(?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)"(?:[^>]+)?>(?P<Title>.+?)</a> https://www.evernote.com/shard/(?P<shard>s\d+)/[\w\d]+/(?P<uid>\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}) - - + + Step 6: Process "See Also: " Links (?<PrefixStrip><div><b><span[^>]*><br/></span></b></div>)?(?: )?(?<SeeAlso>(?<SeeAlsoPrefix><div[^>]*>)(?<SeeAlsoHeader>(?: diff --git a/anknotes/extra/ancillary/sorting.txt b/anknotes/extra/ancillary/sorting.txt index a8a4b47..79fbcf1 100644 --- a/anknotes/extra/ancillary/sorting.txt +++ b/anknotes/extra/ancillary/sorting.txt @@ -8,7 +8,7 @@ 'Si/Sx', 'Sx', 'Sign', - + 'MCC\'s', 'MCC', 'Inheritance', @@ -17,7 +17,7 @@ 'Mechanism', 'MOA', 'Pathophysiology', - + 'Indications', 'Examples', 'Cause', @@ -30,7 +30,7 @@ 'Drug S/E', 'Associated Conditions', 'A/w', - + 'Dx', 'Physical Exam', 'Labs', @@ -44,6 +44,6 @@ 'Management', 'Work Up', 'Tx' - - + + ] \ No newline at end of file diff --git a/anknotes/extra/dev/anknotes_test.py b/anknotes/extra/dev/anknotes_test.py index d1e04de..df4a89e 100644 --- a/anknotes/extra/dev/anknotes_test.py +++ b/anknotes/extra/dev/anknotes_test.py @@ -92,8 +92,8 @@ def process_note_see_also(self): return note_guid = self.fields[FIELD_EVERNOTE_GUID] - # mw.col.db.execute("CREATE TABLE IF NOT EXISTS %s(id INTEGER PRIMARY KEY, note_guid TEXT, uid INTEGER, shard TEXT, guid TEXT, html TEXT, text TEXT ) " % TABLE_SEE_ALSO) - # mw.col.db.execute("CREATE TABLE IF NOT EXISTS %s(id INTEGER PRIMARY KEY, note_guid TEXT, uid INTEGER, shard TEXT, guid TEXT, title TEXT ) " % TABLE_TOC) + # mw.col.db.execute("CREATE TABLE IF NOT EXISTS %s(id INTEGER PRIMARY KEY, note_guid TEXT, uid INTEGER, shard TEXT, guid TEXT, html TEXT, text TEXT ) " % TABLE_SEE_ALSO) + # mw.col.db.execute("CREATE TABLE IF NOT EXISTS %s(id INTEGER PRIMARY KEY, note_guid TEXT, uid INTEGER, shard TEXT, guid TEXT, title TEXT ) " % TABLE_TOC) # mw.col.db.execute("DELETE FROM %s WHERE note_guid = '%s' " % (TABLE_SEE_ALSO, note_guid)) # mw.col.db.execute("DELETE FROM %s WHERE note_guid = '%s' " % (TABLE_TOC, note_guid)) @@ -127,9 +127,9 @@ def process_note_content(self): content = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', r'evernote://view/\2/\1/\3/\3/', content) - ################################## Step 2: Modify Image Links - # Currently anknotes does not support rendering images embedded into an Evernote note. - # As a work around, this code will convert any link to an image on Dropbox, to an embedded <img> tag. + ################################## Step 2: Modify Image Links + # Currently anknotes does not support rendering images embedded into an Evernote note. + # As a work around, this code will convert any link to an image on Dropbox, to an embedded <img> tag. # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page # Step 2.1: Modify HTML links to Dropbox images dropbox_image_url_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))(?P<QueryString>\?dl=(?:0|1))?' @@ -145,7 +145,7 @@ def process_note_content(self): r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', content) - ################################## Step 3: Change white text to transparent + ################################## Step 3: Change white text to transparent # I currently use white text in Evernote to display information that I want to be initially hidden, but visible when desired by selecting the white text. # We will change the white text to a special "occluded" CSS class so it can be visible on the back of cards, and also so we can adjust the color for the front of cards when using night mode content = content.replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') @@ -161,22 +161,21 @@ def process_note_content(self): see_also_match = re.search( r'(?:<div><b><span style="color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);"><br/></span></b></div>)?(?P<SeeAlso>(?:<div>)(?:<span style="color: rgb\(45, 79, 201\);"><b>See Also:(?: )?</b></span>|<b><span style="color: rgb\(45, 79, 201\);">See Also:</span></b>) ?(?P<SeeAlsoLinks>.+))(?P<Suffix></en-note>)', content) - # see_also_match = re.search(r'(?P<PrefixStrip><div><b><span style="color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);"><br/></span></b></div>)?(?P<SeeAlso>(?:<div>)(?P<SeeAlsoHeader><span style="color: rgb\(45, 79, 201\);">(?:See Also|<b>See Also:</b>).*?</span>).+?)(?P<Suffix></en-note>)', content) - + # see_also_match = re.search(r'(?P<PrefixStrip><div><b><span style="color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);"><br/></span></b></div>)?(?P<SeeAlso>(?:<div>)(?P<SeeAlsoHeader><span style="color: rgb\(45, 79, 201\);">(?:See Also|<b>See Also:</b>).*?</span>).+?)(?P<Suffix></en-note>)', content) if see_also_match: content = content.replace(see_also_match.group(0), see_also_match.group('Suffix')) self.fields[FIELD_SEE_ALSO] = see_also_match.group('SeeAlso') self.process_note_see_also() - ################################## Note Processing complete. + ################################## Note Processing complete. self.fields[FIELD_CONTENT] = content def process_note(self): self.model_name = MODEL_EVERNOTE_DEFAULT - # Process Note Content + # Process Note Content self.process_note_content() - # Dynamically determine Anki Card Type + # Dynamically determine Anki Card Type if FIELD_CONTENT in self.fields and "{{c1::" in self.fields[FIELD_CONTENT]: self.model_name = MODEL_EVERNOTE_CLOZE elif EVERNOTE_TAG_REVERSIBLE in self.tags: diff --git a/anknotes/find_deleted_notes.py b/anknotes/find_deleted_notes.py index 763dc49..6e1ac9f 100644 --- a/anknotes/find_deleted_notes.py +++ b/anknotes/find_deleted_notes.py @@ -16,15 +16,15 @@ def do_find_deleted_notes(all_anki_notes=None): """ Error = sqlite.Error - + if not os.path.isfile(FILES.USER.TABLE_OF_CONTENTS_ENEX): log_error('Unable to proceed with find_deleted_notes: TOC enex does not exist.', do_print=True) return False - + enTableOfContents = file(FILES.USER.TABLE_OF_CONTENTS_ENEX, 'r').read() # find = file(os.path.join(PATH, "powergrep-find.txt") , 'r').read().splitlines() # replace = file(os.path.join(PATH, "powergrep-replace.txt") , 'r').read().replace('https://www.evernote.com/shard/s175/nl/19775535/' , '').splitlines() - + all_anknotes_notes = ankDB().all("SELECT guid, title, tagNames FROM %s " % TABLES.EVERNOTE.NOTES) find_guids = {} log_banner(' FIND DELETED EVERNOTE NOTES: UNIMPORTED EVERNOTE NOTES ', FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES) @@ -41,7 +41,7 @@ def do_find_deleted_notes(all_anki_notes=None): if not (',' + TAGS.TOC + ',' in line['tagNames']): if title.upper() == title: log_plain(guid + '::: %-50s: ' % line['tagNames'][1:-1] + title, FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES + '_possibletoc', do_print=True) - + title = clean_title(title) title_safe = str_safe(title) find_guids[guid] = title @@ -60,8 +60,8 @@ def do_find_deleted_notes(all_anki_notes=None): guid = enLink.Guid title = clean_title(enLink.FullTitle) title_safe = str_safe(title) - - if guid in find_guids: + + if guid in find_guids: find_title = find_guids[guid] find_title_safe = str_safe(find_title) if find_title_safe == title_safe: diff --git a/anknotes/html.py b/anknotes/html.py index 985c6fd..bb4a723 100644 --- a/anknotes/html.py +++ b/anknotes/html.py @@ -27,7 +27,7 @@ def strip_tags(html): s.feed(html) html = s.get_data() html = html.replace('__DONT_STRIP_HTML_ENTITIES___', '&') - return html + return html # s = MLStripper() # s.feed(html) # return s.get_data() @@ -35,9 +35,7 @@ def strip_tags(html): def strip_tags_and_new_lines(html): if html is None: return None - return re.sub(r'[\r\n]+', ' ', strip_tags(html)) - - + return re.sub(r'[\r\n]+', ' ', strip_tags(html)) __text_escape_phrases__ = u'&|&|\'|'|"|"|>|>|<|<'.split('|') @@ -226,31 +224,30 @@ def tableify_lines(rows, columns=None, tr_index_offset=0, return_html=True): class EvernoteAccountIDs: uid = SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE shard = SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE - - @property + + @property def Valid(self): return self.is_valid() def is_valid(self, uid=None, shard=None): - if uid is None: uid = self.uid - if shard is None: shard = self.shard - if not uid or not shard: return False - if uid == '0' or uid == SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE or not unicode(uid).isnumeric(): return False - if shard == 's999' or uid == SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE or shard[0] != 's' or not unicode(shard[1:]).isnumeric(): return False - return True - - def __init__(self, uid=None, shard=None): + if uid is None: uid = self.uid + if shard is None: shard = self.shard + if not uid or not shard: return False + if uid == '0' or uid == SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE or not unicode(uid).isnumeric(): return False + if shard == 's999' or uid == SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE or shard[0] != 's' or not unicode(shard[1:]).isnumeric(): return False + return True + def __init__(self, uid=None, shard=None): if uid and shard: - if self.update(uid, shard): return + if self.update(uid, shard): return try: self.uid = mw.col.conf.get(SETTINGS.EVERNOTE.ACCOUNT.UID, SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE) self.shard = mw.col.conf.get(SETTINGS.EVERNOTE.ACCOUNT.SHARD, SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE) - if self.Valid: return + if self.Valid: return except: pass self.uid = SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE self.shard = SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE - + def update(self, uid, shard): if not self.is_valid(uid, shard): return False try: @@ -260,4 +257,4 @@ def update(self, uid, shard): return False self.uid = uid self.shard = shard - return self.Valid + return self.Valid diff --git a/anknotes/logging.py b/anknotes/logging.py index 70e8f73..c8e43fd 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -5,10 +5,11 @@ import re import inspect import shutil -import time +import time # Anknotes Shared Imports from anknotes.constants import * from anknotes.graphics import * +from anknotes.counters import DictCaseInsensitive # from anknotes.stopwatch import clockit # Anki Imports @@ -37,11 +38,10 @@ def show_tooltip(text, time_out=7000, delay=None, do_log=False): if delay: try: return mw.progress.timer(delay, lambda: tooltip(text, time_out), False) except: pass - tooltip(text, time_out) - + tooltip(text, time_out) def counts_as_str(count, max=None): from anknotes.counters import Counter - if isinstance(count, Counter): count = count.val + if isinstance(count, Counter): count = count.val if isinstance(max, Counter): max = max.val if max is None or max <= 0: return str(count).center(3) if count == max: return "All %s" % str(count).center(3) @@ -57,15 +57,15 @@ def show_report(title, header=None, log_lines=None, delay=None, log_header_prefi lines.append('\t'*level + ('\t\t- ' if lines else '') + line[level:]) if len(lines) > 1: lines[0] += ': ' log_text = '<BR>'.join(lines) - if not header and not log_lines: + if not header and not log_lines: i=title.find('> ') show_tooltip(title[0 if i < 0 else i + 2:], delay=delay) else: show_tooltip(log_text.replace('\t', '  '*4), delay=delay) if blank_line_before: log_blank(filename=filename) log(title, filename=filename) - if len(lines) == 1 and not lines[0]: + if len(lines) == 1 and not lines[0]: if hr_if_empty: log(" " + "-" * 185, timestamp=False, filename=filename) - return + return log(" " + "-" * 185 + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True, filename=filename) log_blank(filename=filename) @@ -73,7 +73,7 @@ def show_report(title, header=None, log_lines=None, delay=None, log_header_prefi def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0, cancelButton=False, richText=False, minHeight=None, minWidth=400, styleSheet=None, convertNewLines=True): global imgEvernoteWebMsgBox, icoEvernoteArtcore, icoEvernoteWeb msgDefaultButton = QPushButton(icoEvernoteArtcore, "Okay!", mw) - msgCancelButton = QPushButton(icoTomato, "No Thanks", mw) + if not styleSheet: styleSheet = file(FILES.ANCILLARY.CSS_QMESSAGEBOX, 'r').read() @@ -88,6 +88,7 @@ def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0 messageBox = QMessageBox() messageBox.addButton(msgDefaultButton, QMessageBox.AcceptRole) if cancelButton: + msgCancelButton = QPushButton(icoTomato, "No Thanks", mw) messageBox.addButton(msgCancelButton, QMessageBox.RejectRole) messageBox.setDefaultButton(msgDefaultButton) messageBox.setIconPixmap(imgEvernoteWebMsgBox) @@ -124,29 +125,164 @@ def diffify(content, split=True): for tag in [u'div', u'ol', u'ul', u'li', u'span']: content = content.replace(u"<" + tag, u"\n<" + tag).replace(u"</%s>" % tag, u"</%s>\n" % tag) content = re.sub(r'[\r\n]+', u'\n', content) - return content.splitlines() if split else content - + return content.splitlines() if split else content def generate_diff(value_original, value): - try: - return '\n'.join(list(difflib.unified_diff(diffify(value_original), diffify(value), lineterm=''))) - except: - pass - try: - return '\n'.join( + try: return '\n'.join(list(difflib.unified_diff(diffify(value_original), diffify(value), lineterm=''))) + except: pass + try: return '\n'.join( list(difflib.unified_diff(diffify(value_original.decode('utf-8')), diffify(value), lineterm=''))) - except: - pass - try: - return '\n'.join( + except: pass + try: return '\n'.join( list(difflib.unified_diff(diffify(value_original), diffify(value.decode('utf-8')), lineterm=''))) - except: - pass - try: - return '\n'.join(list( + except: pass + try: return '\n'.join(list( difflib.unified_diff(diffify(value_original.decode('utf-8')), diffify(value.decode('utf-8')), lineterm=''))) - except: - raise + except: raise + + +def PadList(lst, length=25): + newLst = [] + for val in lst: + if isinstance(val, list): newLst.append(PadList(val, length)) + else: newLst.append(val.center(length)) + return newLst +def JoinList(lst, joiners='\n', pad=0, depth=1): + if isinstance(joiners, str) or isinstance(joiners, unicode): joiners = [joiners] + strr = '' + if pad and (isinstance(lst, str) or isinstance(lst, unicode)): return lst.center(pad) + if not lst or not isinstance(lst, list): return lst + delimit=joiners[min(len(joiners), depth)-1] + for val in lst: + if strr: strr += delimit + strr += JoinList(val, joiners, pad, depth+1) + return strr +def PadLines(content, line_padding=32, line_padding_plus=0, line_padding_header='', pad_char=' ', **kwargs): + if not line_padding and not line_padding_plus and not line_padding_header: return content + if not line_padding: line_padding = line_padding_plus; line_padding_plus=True + if str(line_padding).isdigit(): line_padding = pad_char * int(line_padding) + if line_padding_header: content = line_padding_header + content; line_padding_plus = len(line_padding_header) + 1 + elif line_padding_plus is True: line_padding_plus = content.find('\n') + if str(line_padding_plus).isdigit(): line_padding_plus = pad_char * int(line_padding_plus) + return line_padding + content.replace('\n', '\n' + line_padding + line_padding_plus) + +def item_to_list(item, list_from_unknown=True,chrs=''): + if isinstance(item, list): return item + if item and (isinstance(item, unicode) or isinstance(item, str)): + for c in chrs: item=item.replace(c, '|') + return item.split('|') + if list_from_unknown: return [item] + return item +def key_transform(keys, key): + if keys is None: keys = self.keys() + for k in keys: + if k.lower() == key.lower(): return k + return key +def get_kwarg(func_kwargs, key, **kwargs): + kwargs['update_kwargs'] = False + return process_kwarg(func_kwargs, key, **kwargs) + +def process_kwarg(kwargs, key, default=None, replace_none_type=True, update_kwargs=True, return_value=True): + key = key_transform(kwargs.keys(), key) + if key not in kwargs: return (kwargs, default) if update_kwargs else default + val = kwargs[key] + if val is None and replace_none_type: val = default + if not update_kwargs: return val + del kwargs[key] + return kwargs, val +def process_kwargs(kwargs, get_args=None, set_dict=None, name=None, update_kwargs=True): + keys = kwargs.keys() + for key, value in set_dict.items() if set_dict else []: + key = key_transform(keys, key) + if not key in kwargs: kwargs[key]=value + kwargs = DictCaseInsensitive(kwargs, label=name) + if not get_args: return kwargs + keys = kwargs.keys() + gets = [] + for args in get_args: + for arg in args: + if len(arg) is 1 and isinstance(arg[0], list): arg = arg[0] + result = process_kwarg(kwargs, arg[0], arg[1], update_kwargs=update_kwargs) + if update_kwargs: kwargs = result[0]; result = result[1] + gets += [result] + if update_kwargs: return [kwargs] + gets + return gets + +def __get_args__(args, func_kwargs, *args_list, **kwargs_): + kwargs = DictCaseInsensitive({'suffix_type_to_name':True, 'max_args':-1, 'default_value':None, 'return_expanded':True, 'return_values_only':False}) + kwargs.update(kwargs_) + max_args = kwargs.max_args + args = list(args) + # names = item_to_list(names, False) + # if isinstance(names, list): names = [[name, None] for name in names] + # else: names = names.items() + results = DictCaseInsensitive() + max_args = len(args) if max_args < 1 else min(len(args), max_args) + values=[] + args_to_del=[] + get_names = [[names[i*2:i*2+2] for i in range(0, len(names)/2)] if isinstance(names, list) else [[name, None] for name in item_to_list(names)] for names in args_list] + + for get_name in get_names: + for get_name_item in get_name: + if len(get_name_item) is 1 and isinstance(get_name_item[0], list): get_name_item = get_name_item[0] + name = get_name_item[0] + types=get_name_item[1] + print "Name: %s, Types: %s" % (name, str(types[0])) + name = name.replace('*', '') + types = item_to_list(types) + is_none_type = types[0] is None + key = name + ( '_' + types[0].__name__) if kwargs.suffix_type_to_name and not is_none_type else '' + key = key_transform(func_kwargs.keys(), key) + result = DictCaseInsensitive(Match=False, MatchedKWArg=False, MatchedArg=False, Name=key, value=kwargs.default_value) + if key in func_kwargs: + result.value = func_kwargs[key] + del func_kwargs[key] + result.Match = True + result.MatchedKWArg = True + continue + if is_none_type: continue + for i in range(0, max_args): + if i in args_to_del: continue + arg = args[i] + for t in types: + if not isinstance(arg, t): continue + result.value = arg + result.Match = True + result.MatchedArg = True + args_to_del.append(i) + break + if result.Match: break + values.append(result.value) + results[name] = result + args = [x for i, x in enumerate(args) if i not in args_to_del] + results.func_kwargs = func_kwargs + results.args = args + if kwargs.return_values_only: return values + if kwargs.return_expanded: return [args, func_kwargs] + values + return results +def __get_default_listdict_args__(args, kwargs, name): + results_expanded = __get_args__(args, kwargs, [name + '*', [list, str, unicode], name , [dict, DictCaseInsensitive]]) + results_expanded[2] = item_to_list(results_expanded[2], chrs=',') + if results_expanded[2] is None: results_expanded[2] = [] + if results_expanded[3] is None: results_expanded[3] = {} + return results_expanded + +def get_kwarg_values(func_kwargs, *args, **kwargs): + kwargs['update_kwargs'] = False + return get_kwargs(func_kwargs, *args, **kwargs) + +def get_kwargs(func_kwargs, *args_list, **kwargs): + lst = [[args[i*2:i*2+2] for i in range(0, len(args)/2)] if isinstance(args, list) else [[arg, None] for arg in item_to_list(args)] for args in args_list] + return process_kwargs(func_kwargs, get_args=lst, **kwargs) + +def set_kwargs(func_kwargs, *args, **kwargs): + kwargs, name, update_kwargs = get_kwargs(kwargs, ['name', None, 'update_kwargs', None]) + args, kwargs, list, dict = __get_default_listdict_args__(args, kwargs, 'set') + new_args=[]; + for arg in args: new_args += item_to_list(arg, False) + dict.update({key: None for key in list + new_args }) + dict.update(kwargs) + return DictCaseInsensitive(process_kwargs(func_kwargs, set_dict=dict, name=name, update_kwargs=update_kwargs)) def obj2log_simple(content): if not isinstance(content, str) and not isinstance(content, unicode): @@ -157,15 +293,15 @@ def convert_filename_to_local_link(filename): return 'file:///' + filename.replace("\\", "//") class Logger(object): - base_path = None + base_path = None caller_info=None - default_filename=None + default_filename=None def wrap_filename(self, filename=None): - if filename is None: filename = self.default_filename + if filename is None: filename = self.default_filename if self.base_path is not None: filename = os.path.join(self.base_path, filename if filename else '') return filename - + def dump(self, obj, title='', filename=None, *args, **kwargs): filename = self.wrap_filename(filename) log_dump(obj=obj, title=title, filename=filename, *args, **kwargs) @@ -173,24 +309,24 @@ def dump(self, obj, title='', filename=None, *args, **kwargs): def blank(self, filename=None, *args, **kwargs): filename = self.wrap_filename(filename) log_blank(filename=filename, *args, **kwargs) - + def banner(self, title, filename=None, *args, **kwargs): filename = self.wrap_filename(filename) log_banner(title=title, filename=filename, *args, **kwargs) - + def go(self, content=None, filename=None, wrap_filename=True, *args, **kwargs): if wrap_filename: filename = self.wrap_filename(filename) log(content=content, filename=filename, *args, **kwargs) def plain(self, content=None, filename=None, *args, **kwargs): - filename=self.wrap_filename(filename) + filename=self.wrap_filename(filename) log_plain(content=content, filename=filename, *args, **kwargs) - + log = do = add = go def default(self, *args, **kwargs): self.log(wrap_filename=False, *args, **kwargs) - + def __init__(self, base_path=None, default_filename=None, rm_path=False): self.default_filename = default_filename if base_path: @@ -201,32 +337,32 @@ def __init__(self, base_path=None, default_filename=None, rm_path=False): self.base_path = create_log_filename(self.caller_info.Base) if rm_path: rm_log_path(self.base_path) - - - + + + def log_blank(filename=None, *args, **kwargs): log(timestamp=False, content=None, filename=filename, *args, **kwargs) def log_plain(*args, **kwargs): log(timestamp=False, *args, **kwargs) - + def rm_log_path(filename='*', subfolders_only=False, retry_errors=0): path = os.path.dirname(os.path.abspath(get_log_full_path(filename))) - if path is FOLDERS.LOGS or path in FOLDERS.LOGS: return + if path is FOLDERS.LOGS or path in FOLDERS.LOGS: return rm_log_path.errors = [] def del_subfolder(arg=None,dirname=None,filenames=None, is_subfolder=True): def rmtree_error(f, p, e): rm_log_path.errors += [p] - if is_subfolder and dirname is path: return + if is_subfolder and dirname is path: return shutil.rmtree(dirname, onerror=rmtree_error) if not subfolders_only: del_subfolder(dirname=path, is_subfolder=False) else: os.path.walk(path, del_subfolder, None) - if rm_log_path.errors: - if retry_errors > 5: + if rm_log_path.errors: + if retry_errors > 5: print "Unable to delete log path" log("Unable to delete log path as requested", filename) - return + return time.sleep(1) rm_log_path(filename, subfolders_only, retry_errors + 1) @@ -256,7 +392,7 @@ def get_log_full_path(filename=None, extension='log', as_url_link=False, prefix= filename_suffix = '\\' + filename[1:] log_base_name = '' filename = None - if filename is None: + if filename is None: if FILES.LOGS.USE_CALLER_NAME: caller = caller_name() if caller: @@ -270,8 +406,8 @@ def get_log_full_path(filename=None, extension='log', as_url_link=False, prefix= if filename[0] is '+': filename = filename[1:] filename = (log_base_name + '-' if log_base_name and log_base_name[-1] != '\\' else '') + filename - - filename += filename_suffix + + filename += filename_suffix filename += ('.' if filename and filename[-1] is not '.' else '') + extension filename = re.sub(r'[^\w\-_\.\\]', '_', filename) full_path = os.path.join(FOLDERS.LOGS, filename) @@ -280,44 +416,37 @@ def get_log_full_path(filename=None, extension='log', as_url_link=False, prefix= if fn != '.' + extension: fn = '-' + fn full_path = os.path.join(parent, prefix + fn) if not os.path.exists(os.path.dirname(full_path)): - os.makedirs(os.path.dirname(full_path)) + os.makedirs(os.path.dirname(full_path)) if as_url_link: return convert_filename_to_local_link(full_path) return full_path -def encode_log_text(content): - if not isinstance(content, str) and not isinstance(content, unicode): return content - try: - return content.encode('utf-8') - except Exception: - return content - -# @clockit -def log(content=None, filename=None, prefix='', clear=False, timestamp=True, extension='log', - replace_newline=None, do_print=False, encode_text=True, print_timestamp=False): +def encode_log_text(content, encode_text=True, **kwargs): + if not encode_text or not isinstance(content, str) and not isinstance(content, unicode): return content + try: return content.encode('utf-8') + except Exception: return content +# @clockit +def log(content=None, filename=None, prefix='', clear=False, extension='log', + do_print=False, print_timestamp=False, replace_newline=None,timestamp=True, **kwargs): + kwargs = set_kwargs(kwargs, 'line_padding') if content is None: content = '' else: content = obj2log_simple(content) if len(content) == 0: content = '{EMPTY STRING}' - if content[0] == "!": - content = content[1:] - prefix = '\n' + if content[0] == "!": content = content[1:]; prefix = '\n' if filename and filename[0] is '+': - summary = " ** CROSS-POST TO %s: " % filename[1:] + content + original_log = filename[1:].upper() + summary = " ** %s%s: " % ('' if original_log == 'ERROR' else 'CROSS-POST TO ', original_log) + content log(summary[:200]) - full_path = get_log_full_path(filename, extension) - st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' - if timestamp or replace_newline is True: + full_path = get_log_full_path(filename, extension) + st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' + content = PadLines(content, **kwargs) + if timestamp or replace_newline: try: content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content) - except UnicodeDecodeError: - content = content.decode('utf-8') - content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content) - if encode_text: content = encode_log_text(content) - contents = prefix + ' ' + st + content - with open(full_path, 'w+' if clear else 'a+') as fileLog: + except UnicodeDecodeError: content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content.decode('utf-8')) + contents = prefix + ' ' + st + encode_log_text(content, **kwargs) + with open(full_path, 'w+' if clear else 'a+') as fileLog: try: print>> fileLog, contents - except UnicodeEncodeError: - contents = contents.encode('utf-8') - print>> fileLog, contents + except UnicodeEncodeError: contents = contents.encode('utf-8'); print>> fileLog, contents if do_print: print contents if print_timestamp else content def log_sql(content, **kwargs): @@ -479,12 +608,11 @@ def create_log_filename(strr): strr = strr.replace('.', '\\') strr = re.sub(r"(^|\\)([^\\]+)\\\2(\b.|\\.|$)", r"\1\2\\", strr) strr = re.sub(r"^\\*(.+?)\\*$", r"\1", strr) - return strr - -# @clockit + return strr +# @clockit def caller_name(skip=None, simplify=True, return_string=False, return_filename=False): if skip is None: names = [__caller_name__(i,simplify) for i in range(0,20)] - else: names = [__caller_name__(skip, simplify=simplify)] + else: names = [__caller_name__(skip, simplify=simplify)] for c in [c for c in names if c and c.Base]: return create_log_filename(c.Base) if return_filename else c.Base if return_string else c return "" if return_filename or return_string else None diff --git a/anknotes/menu.py b/anknotes/menu.py index 5376e1c..6c43ba7 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -12,7 +12,7 @@ from anknotes.constants import * # Anknotes Main Imports -import anknotes.Controller +import anknotes.Controller # from anknotes.Controller import Controller # Anki Imports @@ -71,14 +71,14 @@ def anknotes_setup_menu(): add_menu_items(menu_items) def auto_reload_wrapper(function): return lambda: auto_reload_modules(function) - + def auto_reload_modules(function): if ANKNOTES.DEVELOPER_MODE.ENABLED and ANKNOTES.DEVELOPER_MODE.AUTO_RELOAD_MODULES: - anknotes.shared = reload(anknotes.shared) + anknotes.shared = reload(anknotes.shared) if not anknotes.Controller: importlib.import_module('anknotes.Controller') reload(anknotes.Controller) function() - + def add_menu_items(menu_items, parent=None): if not parent: parent = mw.form.menubar for title, action in menu_items: @@ -91,14 +91,14 @@ def add_menu_items(menu_items, parent=None): else: checkable = False if isinstance(action, dict): - options = action + options = action action = options['action'] if 'checkable' in options: checkable = options['checkable'] # if ANKNOTES.DEVELOPER_MODE.ENABLED and ANKNOTES.DEVELOPER_MODE.AUTO_RELOAD_MODULES: action = auto_reload_wrapper(action) menu_action = QAction(_(title), mw, checkable=checkable) - parent.addAction(menu_action) + parent.addAction(menu_action) parent.connect(menu_action, SIGNAL("triggered()"), action) if checkable: anknotes_checkable_menu_items[title] = menu_action @@ -173,7 +173,7 @@ def find_deleted_notes(automated=False): returnedData = find_deleted_notes.do_find_deleted_notes() if returnedData is False: showInfo("An error occurred while executing the script. Please ensure you created the TOC note and saved it as instructed in the previous dialog.") - return + return lines = returnedData['Summary'] info = tableify_lines(lines, '#|Type|Info') # info = '<table><tr class=tr0><td class=t1>#</td><td class=t2>Type</td><td class=t3></td></tr>%s</table>' % '\n'.join(lines) @@ -183,7 +183,7 @@ def find_deleted_notes(automated=False): anki_dels = returnedData['AnkiOrphans'] anki_dels_count = len(anki_dels) missing_evernote_notes = returnedData['MissingEvernoteNotes'] - missing_evernote_notes_count = len(missing_evernote_notes) + missing_evernote_notes_count = len(missing_evernote_notes) showInfo(info, richText=True, minWidth=600) db_changed = False if anknotes_dels_count > 0: @@ -227,18 +227,18 @@ def validate_pending_notes(showAlerts=True, uploadAfterValidation=True, callback stdoutdata = re.sub(' +', ' ', stdoutdata) info = ("ERROR: {%s}<HR>" % stderrdata) if stderrdata else '' allowUpload = True - if showAlerts: + if showAlerts: log('vpn stdout: ' + FILES.SCRIPTS.VALIDATION + '\n' + stdoutdata) tds = [[str(count), '<a href="%s">VIEW %s VALIDATIONS LOG</a>' % (fn, key.upper())] for key, fn, count in [ [key, get_log_full_path('MakeNoteQueue\\' + key, as_url_link=True), int(re.search(r'CHECKING +(\d{1,3}) +' + key.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1))] - for key in ['Pending', 'Successful', 'Failed']] if count > 0] + for key in ['Pending', 'Successful', 'Failed']] if count > 0] if not tds: show_tooltip("No notes found in the validation queue.") - allowUpload = False + allowUpload = False else: info += tableify_lines(tds, '#|Results') successful = int(re.search(r'CHECKING +(\d{1,3}) +' + 'Successful'.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1)) - allowUpload = (uploadAfterValidation and successful > 0) + allowUpload = (uploadAfterValidation and successful > 0) allowUpload = allowUpload & showInfo("Completed: %s<BR>%s" % ( 'Press Okay to begin uploading %d successfully validated note(s) to the Evernote Servers' % successful if (uploadAfterValidation and successful > 0) else '', info), cancelButton=(successful > 0), richText=True) @@ -269,7 +269,7 @@ def see_also(steps=None, showAlerts=None, validationComplete=False): if isinstance(steps, int): steps = [steps] multipleSteps = (len(steps) > 1) if showAlerts is None: showAlerts = not multipleSteps - remaining_steps=steps + remaining_steps=steps if 1 in steps: # Should be unnecessary once See Also algorithms are finalized log(" > See Also: Step 1: Process Un Added See Also Notes") @@ -291,10 +291,10 @@ def see_also(steps=None, showAlerts=None, validationComplete=False): controller.anki.extract_links_from_toc() if 6 in steps: log(" > See Also: Step 6: Insert TOC/Outline Links Into Anki Notes' See Also Field") - controller.anki.insert_toc_into_see_also() + controller.anki.insert_toc_into_see_also() if 7 in steps: log(" > See Also: Step 7: Update See Also Footer In Evernote Notes") - from anknotes import detect_see_also_changes + from anknotes import detect_see_also_changes detect_see_also_changes.main() if 8 in steps: if validationComplete: diff --git a/anknotes/settings.py b/anknotes/settings.py index 4e02ecd..9fbadbf 100644 --- a/anknotes/settings.py +++ b/anknotes/settings.py @@ -277,8 +277,8 @@ def setup_evernote(self): # Add Horizontal Row Separator form.addRow(gen_qt_hr()) - ############################ PAGINATION ########################## - # Evernote Pagination: Current Page + ############################ PAGINATION ########################## + # Evernote Pagination: Current Page evernote_pagination_current_page_spinner = QSpinBox() evernote_pagination_current_page_spinner.setStyleSheet("QSpinBox { font-weight: bold; color: rgb(173, 0, 0); } ") evernote_pagination_current_page_spinner.setPrefix("PAGE: ") @@ -302,10 +302,10 @@ def setup_evernote(self): # Add Form Row for Evernote Pagination form.addRow("<b>Pagination:</b>", hbox) - # Add Query Form to Group Box + # Add Query Form to Group Box group.setLayout(form) - # Add Query Group Box to Main Layout + # Add Query Group Box to Main Layout layout.addWidget(group) ########################## DECK ########################## @@ -322,7 +322,7 @@ def setup_evernote(self): default_anki_deck.setText(mw.col.conf.get(SETTINGS.ANKI.DECKS.BASE, SETTINGS.ANKI.DECKS.BASE_DEFAULT_VALUE)) default_anki_deck.connect(default_anki_deck, SIGNAL("textEdited(QString)"), update_default_anki_deck) - # Add Form Row for Default Anki Deck + # Add Form Row for Default Anki Deck hbox = QHBoxLayout() hbox.insertSpacing(0, 33) hbox.addWidget(default_anki_deck) @@ -336,7 +336,7 @@ def setup_evernote(self): mw.col.conf.get(SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION, True)) anki_deck_evernote_notebook_integration.stateChanged.connect(update_anki_deck_evernote_notebook_integration) - # Change Visibility of Deck Options + # Change Visibility of Deck Options update_anki_deck_visibilities() # Add Form Row for Evernote Notebook Integration @@ -361,7 +361,7 @@ def setup_evernote(self): SIGNAL("textEdited(QString)"), update_evernote_tags_to_delete) - # Delete Tags To Import + # Delete Tags To Import delete_evernote_query_tags = QCheckBox(" Also Delete Search Tags", self) delete_evernote_query_tags.setChecked(mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False)) delete_evernote_query_tags.stateChanged.connect(update_delete_evernote_query_tags) @@ -396,13 +396,13 @@ def setup_evernote(self): hbox.addWidget(update_existing_notes) form.addRow("<b>Note Updating:</b>", hbox) - # Add Note Update Method Form to Group Box + # Add Note Update Method Form to Group Box group.setLayout(form) - # Add Note Update Method Group Box to Main Layout + # Add Note Update Method Group Box to Main Layout layout.addWidget(group) - # Update Visibilities of Query Options + # Update Visibilities of Query Options evernote_query_text_changed() update_evernote_query_visibilities() @@ -585,8 +585,7 @@ def evernote_query_text_changed(): evernote_query_use_excluded_tags.setChecked(False) else: evernote_query_use_excluded_tags.setEnabled(True) - evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True)) - + evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True)) if not tags and not all_inactive: evernote_query_use_tags.setEnabled(False) evernote_query_use_tags.setChecked(False) @@ -711,7 +710,7 @@ def update_evernote_query_last_updated_value_absolute_time(time_value): def generate_evernote_query(): - query = "" + query = "" if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK, False): query += 'notebook:"%s" ' % mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTEBOOK, SETTINGS.EVERNOTE.QUERY.NOTEBOOK_DEFAULT_VALUE).strip() @@ -726,13 +725,13 @@ def generate_evernote_query(): tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() for tag in tags: tag = tag.strip() - if ' ' in tag: tag = '"%s"' % tag + if ' ' in tag: tag = '"%s"' % tag query += 'tag:%s ' % tag if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True): tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS, '').replace(',', ' ').split() for tag in tags: tag = tag.strip() - if ' ' in tag: tag = '"%s"' % tag + if ' ' in tag: tag = '"%s"' % tag query += '-tag:%s ' % tag if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_LAST_UPDATED, False): query += " updated:%s " % evernote_query_last_updated_value_get_current_value() diff --git a/anknotes/shared.py b/anknotes/shared.py index 4074067..5ebb939 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -4,13 +4,11 @@ from pysqlite2 import dbapi2 as sqlite except ImportError: from sqlite3 import dbapi2 as sqlite -import os +import os import re -import sys - -### Check if in Anki -inAnki='anki' in sys.modules - +import sys +### Check if in Anki +inAnki='anki' in sys.modules ### Anknotes Imports from anknotes.constants import * from anknotes.logging import * @@ -20,13 +18,11 @@ ### Anki and Evernote Imports if inAnki: - from aqt import mw + from aqt import mw from aqt.qt import QIcon, QPixmap, QPushButton, QMessageBox - from aqt.utils import tooltip from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ EDAMNotFoundException - -# log('Checking for log at %s:\n%s' % (__name__, dir(log)), 'import') + def get_friendly_interval_string(lastImport): if not lastImport: return "" td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) @@ -50,8 +46,7 @@ def clean_evernote_css(strr): remove_style_attrs = '-webkit-text-size-adjust: auto|-webkit-text-stroke-width: 0px|background-color: rgb(255, 255, 255)|color: rgb(0, 0, 0)|font-family: Tahoma|font-size: medium;|font-style: normal|font-variant: normal|font-weight: normal|letter-spacing: normal|orphans: 2|text-align: -webkit-auto|text-indent: 0px|text-transform: none|white-space: normal|widows: 2|word-spacing: 0px|word-wrap: break-word|-webkit-nbsp-mode: space|-webkit-line-break: after-white-space'.replace( '(', '\\(').replace(')', '\\)') # 'margin: 0px; padding: 0px 0px 0px 40px; ' - return re.sub(r' ?(%s);? ?' % remove_style_attrs, '', strr).replace(' style=""', '') - + return re.sub(r' ?(%s);? ?' % remove_style_attrs, '', strr).replace(' style=""', '') class UpdateExistingNotes: IgnoreExistingNotes, UpdateNotesInPlace, DeleteAndReAddNotes = range(3) @@ -61,15 +56,15 @@ class EvernoteQueryLocationType: def __check_tag_name__(v, tags_to_delete): return v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete) and (not hasattr(v, 'name') or getattr(v, 'name') not in tags_to_delete) - + def get_tag_names_to_import(tagNames, evernoteQueryTags=None, evernoteTagsToDelete=None, keepEvernoteTags=None, deleteEvernoteQueryTags=None): - if keepEvernoteTags is None: keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE) + if keepEvernoteTags is None: keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE) if not keepEvernoteTags: return {} if isinstance(tagNames, dict) else [] - if evernoteQueryTags is None: evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() + if evernoteQueryTags is None: evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() if deleteEvernoteQueryTags is None: deleteEvernoteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False) if evernoteTagsToDelete is None: evernoteTagsToDelete = mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "").replace(',', ' ').split() tags_to_delete = evernoteQueryTags if deleteEvernoteQueryTags else [] + evernoteTagsToDelete - if isinstance(tagNames, dict): + if isinstance(tagNames, dict): return {k: v for k, v in tagNames.items() if __check_tag_name__(v, tags_to_delete)} return sorted([v for v in tagNames if __check_tag_name__(v, tags_to_delete)]) diff --git a/anknotes/stopwatch/__init__.py b/anknotes/stopwatch/__init__.py index 64a048b..2270fdb 100644 --- a/anknotes/stopwatch/__init__.py +++ b/anknotes/stopwatch/__init__.py @@ -48,13 +48,13 @@ class ActionInfo(object): __created_str__ = " Added to Anki" __updated_str__ = " Updated in Anki" __queued_str__ = " for Upload to Evernote" - - @property + + @property def automated(self): return self.__automated - + @property - def ActionShort(self): - try: + def ActionShort(self): + try: if self.__action_short: return self.__action_short finally: return (self.ActionBase.upper() + 'ING').replace(' OFING', 'ING').replace("CREATEING", "CREATING") + ' ' + self.RowItemBase.upper() @@ -102,13 +102,11 @@ def Label(self): @property def Max(self): - if not self.__max: return -1 - if isinstance(self.__max, Counter): return self.__max.val - return self.__max - + if not self.__max: return -1 + if isinstance(self.__max, Counter): return self.__max.val + return self.__max @Max.setter - def Max(self, value): self.__max = value - + def Max(self, value): self.__max = value @property def Interval(self): return self.__interval @@ -124,7 +122,7 @@ def willReportProgress(self): return self.Max > self.Interval def FormatLine(self, text, num=None): - if isinstance(num, Counter): num = num.val + if isinstance(num, Counter): num = num.val return text.format(num=('%'+str(len(str(self.Max)))+'d ') % num if num else '', row_sources=self.RowSource.replace('(s)', 's'), rows=self.RowItemFull.replace('(s)', 's'), @@ -137,11 +135,11 @@ def FormatLine(self, text, num=None): def ActionLine(self, title, text,num=None): return " > %s %s: %s" % (self.Action, title, self.FormatLine(text, num)) - @property + @property def Aborted(self): return self.ActionLine("Aborted", "No Qualifying {row_sources} Found") - @property + @property def Initiated(self): return self.ActionLine("Initiated", "{num}{r} Found", self.Max) @@ -149,14 +147,13 @@ def BannerHeader(self, append_newline=False): log_banner(self.Action.upper(), self.Label, append_newline=False) def setStatus(self, status): - self.Status = status - return status - - @property + self.Status = status + return status + @property def enabled(self): return self.__enabled - + def displayInitialInfo(self,max=None,interval=None, automated=None, enabled=None): - if max: + if max: self.__max = max if interval: self.__interval = interval if automated is not None: self.__automated = automated @@ -175,7 +172,7 @@ def displayInitialInfo(self,max=None,interval=None, automated=None, enabled=None if self.willReportProgress: log_banner(self.Action.upper(), self.Label, append_newline=False) return self.setStatus(EvernoteAPIStatus.Initialized) - + def __init__(self, action_base='Upload of Validated Evernote Notes', row_item_base=None, row_item_full=None,action_template=None, label=None, auto_label=True, max=None, automated=False, enabled=True, interval=None, row_source=None): if label is None and auto_label is True: label = caller_name(return_filename=True) @@ -184,7 +181,7 @@ def __init__(self, action_base='Upload of Validated Evernote Notes', row_item_ba action_base = actions[0] if len(actions) == 1: action_base = actions[0] - row_item_base = action_base + row_item_base = action_base else: if actions[1] == 'of': action_base += ' of' @@ -200,8 +197,8 @@ def __init__(self, action_base='Upload of Validated Evernote Notes', row_item_ba self.__row_source = row_source self.__action_template = action_template self.__automated=automated - self.__enabled=enabled - self.__label = label + self.__enabled=enabled + self.__label = label self.__max = max self.__interval = interval @@ -219,25 +216,23 @@ class Timer(object): __info = None """:type : Timer""" - @property + @property def counts(self): - if self.__counts is None: + if self.__counts is None: log("Init counter from property: " + repr(self.__counts), "counters") self.__counts = EvernoteCounter() return self.__counts - + @counts.setter def counts(self, value): - self.__counts = value - + self.__counts = value @property def laps(self): return len(self.__times) @property def is_success(self): - return self.counts.success - + return self.counts.success @property def parent(self): return self.__parent_timer @@ -246,12 +241,12 @@ def parent(self): def label(self): if self.info: return self.info.Label return "" - + @label.setter - def label(self,value): - if self.info and isinstance(self.info, ActionInfo): - self.info.__label = value - return + def label(self,value): + if self.info and isinstance(self.info, ActionInfo): + self.info.__label = value + return self.__info = ActionInfo(value, label=value) @parent.setter @@ -353,46 +348,43 @@ def isProgressCheck(self): if not self.counts.max: return False return self.count % max(self.__interval, 1) is 0 - @property + @property def status(self): - if self.hasActionInfo: return self.info.Status - return self.__status - + if self.hasActionInfo: return self.info.Status + return self.__status @status.setter def status(self, value): - if self.hasActionInfo: self.info.Status = value - + if self.hasActionInfo: self.info.Status = value def autoStep(self, returned_tuple, title=None, update=None, val=None): self.step(title, val) return self.extractStatus(returned_tuple, update) - + def extractStatus(self, returned_tuple, update=None): self.report_result = self.reportStatus(returned_tuple[0], update) if len(returned_tuple) == 2: return returned_tuple[1] return returned_tuple[1:] - + def checkLimits(self): - if not -1 < self.counts.max_allowed <= self.counts.updated + self.counts.created: return True - log("Count exceeded- Breaking with status " + str(self.status), self.label) + if not -1 < self.counts.max_allowed <= self.counts.updated + self.counts.created: return True + log("Count exceeded- Breaking with status " + str(self.status), self.label) self.reportStatus(EvernoteAPIStatus.ExceededLocalLimit) - return False - + return False def reportStatus(self, status, update=None): """ :type status : EvernoteAPIStatus """ - self.status = status + self.status = status if status.IsError: return self.reportError(save_status=False) if status == EvernoteAPIStatus.RequestQueued: return self.reportQueued(save_status=False) if status.IsSuccess: return self.reportSuccess(update, save_status=False) - if status == EvernoteAPIStatus.ExceededLocalLimit: return status + if status == EvernoteAPIStatus.ExceededLocalLimit: return status self.counts.unhandled.step() return False def reportSkipped(self, save_status=True ): if save_status: self.status = EvernoteAPIStatus.RequestSkipped return self.counts.skipped.step() - + def reportSuccess(self, update=None, save_status=True): if save_status: self.status = EvernoteAPIStatus.Success if update: self.counts.updated.completed.step() @@ -408,7 +400,7 @@ def reportQueued(self, save_status=True, update=None): if update: return self.counts.updated.queued.step() return self.counts.created.queued.step() - @property + @property def ReportHeader(self): return None if not self.counts.total else self.info.FormatLine("%s {r} were processed" % counts_as_str(self.counts.total, self.counts.max), self.counts.total) @@ -416,18 +408,18 @@ def ReportSingle(self, text, count, subtext='', queued_text='', queued=0, subcou if not count: return [] if isinstance(count, Counter) and process_subcounts: if count.queued: queued = count.queued.val - if count.completed.subcount: subcount = count.completed.subcount.val + if count.completed.subcount: subcount = count.completed.subcount.val if not queued_text: queued_text = self.info.__queued_str__ strs = [self.info.FormatLine("%s {r} %s" % (counts_as_str(count), text), self.count)] if process_subcounts: if queued: strs.append("-%-3d of these were queued%s" % (queued, queued_text)) - if subcount: strs.append("-%-3d of these were successfully%s " % (subcount, subtext)) + if subcount: strs.append("-%-3d of these were successfully%s " % (subcount, subtext)) return strs def Report(self, subcount_created=0, subcount_updated=0): str_tips = [] - self.counts.created.completed.subcount = subcount_created - self.counts.updated.completed.subcount = subcount_updated + self.counts.created.completed.subcount = subcount_created + self.counts.updated.completed.subcount = subcount_updated str_tips += self.ReportSingle('were newly created', self.counts.created, self.info.__created_str__) str_tips += self.ReportSingle('already exist and were updated', self.counts.updated, self.info.__updated_str__) str_tips += self.ReportSingle('already exist but were unchanged', self.counts.skipped, process_subcounts=False) @@ -455,14 +447,13 @@ def info(self): """ return self.__info - @property + @property def did_break(self): return self.__did_break - - def reportNoBreak(self): self.__did_break = False - - @property + + def reportNoBreak(self): self.__did_break = False + @property def should_retry(self): return self.did_break and self.status != EvernoteAPIStatus.ExceededLocalLimit - + @property def automated(self): if not self.info: return False @@ -475,20 +466,20 @@ def __init__(self, max=None, interval=100, info=None, infoStr=None, automated=No """ :type info : ActionInfo """ - simple_label = False - self.counts = EvernoteCounter() + simple_label = False + self.counts = EvernoteCounter() self.counts.max_allowed = -1 if max_allowed is None else max_allowed self.__interval = interval if type(info) == str or type(info) == unicode: info = ActionInfo(info) if infoStr and not info: info = ActionInfo(infoStr) if label and not info: simple_label = True - if display_initial_info is None: display_initial_info = False + if display_initial_info is None: display_initial_info = False info = ActionInfo(label, label=label) elif label: info.__label = label if max is not None and info and (info.Max is None or info.Max <= 0): info.Max = max self.counts.max = -1 if max is None else max - self.__did_break = True + self.__did_break = True self.__info = info self.__action_initialized = False self.__action_attempted = self.hasActionInfo and (display_initial_info is not False) @@ -505,8 +496,7 @@ def willReportProgress(self): @property def actionInitializationFailed(self): - return self.__action_attempted and not self.__action_initialized - + return self.__action_attempted and not self.__action_initialized @property def interval(self): return max(self.__interval, 1) @@ -518,8 +508,8 @@ def reset(self, reset_counter = True): # keep = [] # if self.counts: # keep = [self.counts.max, self.counts.max_allowed] - # del self.__counts - if reset_counter: + # del self.__counts + if reset_counter: log("Resetting counter", 'counters') if self.counts is None: self.counts = EvernoteCounter() else: self.counts.reset() diff --git a/anknotes/structs.py b/anknotes/structs.py index 6fcded8..058dadb 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -3,6 +3,7 @@ from anknotes.db import * from anknotes.enum import Enum from anknotes.html import strip_tags +from anknotes.logging import PadList, JoinList from anknotes.enums import * from anknotes.EvernoteNoteTitle import EvernoteNoteTitle @@ -35,18 +36,18 @@ def __attr_from_key__(key): def keys(self): return self._valid_attributes_() - - def items(self): + + def items(self): return [self.getAttribute(key) for key in self.__attr_order__] - + def sqlUpdateQuery(self): columns = self.__attr_order__ if self.__attr_order__ else self.__sql_columns__ return "INSERT OR REPLACE INTO `%s`(%s) VALUES (%s)" % (self.__sql_table__, '`' + '`,`'.join(columns) + '`', ', '.join(['?']*len(columns))) - + def sqlSelectQuery(self, allColumns=True): return "SELECT %s FROM %s WHERE %s = '%s'" % ( '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) - + def getFromDB(self, allColumns=True): query = "SELECT %s FROM %s WHERE %s = '%s'" % ( '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) @@ -70,7 +71,7 @@ def Where(self, value): def getAttribute(self, key, default=None, raiseIfInvalidKey=False): if not self.hasAttribute(key): if raiseIfInvalidKey: raise KeyError - return default + return default return getattr(self, self.__attr_from_key__(key)) def hasAttribute(self, key): @@ -82,7 +83,7 @@ def setAttribute(self, key, value): self.getFromDB() elif self._is_valid_attribute_(key): setattr(self, self.__attr_from_key__(key), value) - else: + else: raise KeyError("%s: %s is not a valid attribute" % (self.__class__.__name__, key)) def setAttributeByObject(self, key, keyed_object): @@ -102,10 +103,10 @@ def setFromKeyedObject(self, keyed_object, keys=None): keyed_object = keyed_object.groupdict() elif hasattr(keyed_object, 'keys'): keys = getattrcallable(keyed_object, 'keys') - elif hasattr(keyed_object, self.__sql_where__): - for key in self.keys(): + elif hasattr(keyed_object, self.__sql_where__): + for key in self.keys(): if hasattr(keyed_object, key): self.setAttribute(key, getattr(keyed_object, key)) - return True + return True else: return False @@ -130,7 +131,7 @@ def _valid_attributes_(self): def _is_valid_attribute_(self, attribute): return (attribute[0].lower() + attribute[1:]) in self._valid_attributes_() - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs): if isinstance(self.__sql_columns__, str): self.__sql_columns__ = [self.__sql_columns__] if isinstance(self.__attr_order__, str) or isinstance(self.__attr_order__, unicode): self.__attr_order__ = self.__attr_order__.replace('|', ' ').split(' ') @@ -179,7 +180,7 @@ def Title(self, value): :param value: :type value : EvernoteNoteTitle.EvernoteNoteTitle | str | unicode :return: - """ + """ self.__title__ = anknotes.EvernoteNoteTitle.EvernoteNoteTitle(value) """:type : EvernoteNoteTitle.EvernoteNoteTitle""" @@ -252,7 +253,7 @@ class EvernoteAPIStatusOld(AutoNumber): def __getitem__(self, item): """:rtype : EvernoteAPIStatus""" - + return super(self.__class__, self).__getitem__(item) # def __new__(cls, *args, **kwargs): @@ -284,7 +285,7 @@ class EvernoteAPIStatus(AutoNumberedEnum): """:type : EvernoteAPIStatus""" Cancelled = -50 """:type : EvernoteAPIStatus""" - Disabled = -25 + Disabled = -25 """:type : EvernoteAPIStatus""" EmptyRequest = -10 """:type : EvernoteAPIStatus""" @@ -295,7 +296,7 @@ class EvernoteAPIStatus(AutoNumberedEnum): RequestQueued = -3 """:type : EvernoteAPIStatus""" ExceededLocalLimit = -2 - """:type : EvernoteAPIStatus""" + """:type : EvernoteAPIStatus""" DelayedDueToRateLimit = -1 """:type : EvernoteAPIStatus""" Success = 0 @@ -324,8 +325,8 @@ class EvernoteAPIStatus(AutoNumberedEnum): @property def IsError(self): return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value - - @property + + @property def IsDelayableError(self): return self.value == EvernoteAPIStatus.RateLimitError.value or self.value == EvernoteAPIStatus.SocketError.value @@ -408,11 +409,11 @@ def SummaryLines(self): ## Local Calls if self.LocalDownloadsOccurred: lines.append( - "-%d %s note(s) were unexpectedly found in the local db and did not require an API call." % (self.Local, add_update_strs[0])) - lines.append("-%d %s note(s) required an API call" % (self.Remote, add_update_strs[0])) + "-%3d %s note%s unexpectedly found in the local db and did not require an API call." % (self.Local, add_update_strs[0], 's were' if self.Local > 1 else ' was')) + lines.append("-%3d %s note(s) required an API call" % (self.Remote, add_update_strs[0])) if not self.ImportType == EvernoteImportType.Add and self.AlreadyUpToDate > 0: lines.append( - "-%3d existing note(s) are already up-to-date with Evernote's servers, so they were not retrieved." % self.AlreadyUpToDate) + "-%3d existing note%s already up-to-date with Evernote's servers, so %s not retrieved." % (self.AlreadyUpToDate, 's are' if self.Local > 1 else ' is', 'they were' if self.Local > 1 else 'it was')) ## Anki Status if self.DownloadSuccess: @@ -549,14 +550,15 @@ def Status(self): return s1 @property - def Summary(self): - lst = [ - "New Notes (%d)" % self.Adding, - "Existing Out-Of-Date Notes (%d)" % self.Updating, - "Existing Up-To-Date Notes (%d)" % self.AlreadyUpToDate + def SummaryList(self): + return [ + "New Notes: %d" % self.Adding, + "Out-Of-Date Notes: %d" % self.Updating, + "Up-To-Date Notes: %d" % self.AlreadyUpToDate ] - return ' > '.join(lst) + @property + def Summary(self): return JoinList(self.SummaryList, ' | ', 31) def loadAlreadyUpdated(self, db_guids): self.GUIDs.Server.Existing.UpToDate = db_guids @@ -633,10 +635,7 @@ def __init__(self, anki=None, metadataProgress=None, server_evernote_guids=None, class EvernoteMetadataProgress: - Page = 1 - Total = -1 - Current = -1 - UpdateCount = 0 + Page = Total = Current = UpdateCount = -1 Status = EvernoteAPIStatus.Uninitialized Guids = [] NotesMetadata = {} @@ -649,36 +648,38 @@ def IsFinished(self): return self.Remaining <= 0 @property - def List(self): - return ["Total Notes: %d" % self.Total, + def SummaryList(self): + return [["Total Notes: %d" % self.Total, + "Total Pages: %d" % self.TotalPages, "Returned Notes: %d" % self.Current, - "Result Range: %d-%d" % (self.Offset, self.Completed), - "Remaining Notes: %d" % self.Remaining, - "Update Count: %d" % self.UpdateCount] + "Result Range: %d-%d" % (self.Offset, self.Completed) + ], + ["Remaining Notes: %d" % self.Remaining, + "Remaining Pages: %d" % self.RemainingPages, + "Update Count: %d" % self.UpdateCount]] @property - def ListPadded(self): - lst = [] - for val in self.List: + def Summary(self): return JoinList(self.SummaryList, ['\n', ' | '], 31) - lst.append(val.center(25)) - return lst + @property + def QueryMax(self): return 250 + @property + def Offset(self): return (self.Page - 1) * self.QueryMax @property - def Summary(self): - return ' | '.join(self.ListPadded) + def TotalPages(self): + if self.Total is -1: return -1 + p = float(self.Total) / self.QueryMax + return int(p) + (1 if p > int(p) else 0) @property - def Offset(self): - return (self.Page - 1) * 250 + def RemainingPages(self): return self.TotalPages - self.Page @property - def Completed(self): - return self.Current + self.Offset + def Completed(self): return self.Current + self.Offset @property - def Remaining(self): - return self.Total - self.Completed + def Remaining(self): return self.Total - self.Completed def __init__(self, page=1): self.Page = int(page) diff --git a/anknotes/toc.py b/anknotes/toc.py index 07dc9ce..b96be48 100644 --- a/anknotes/toc.py +++ b/anknotes/toc.py @@ -81,9 +81,9 @@ def sortIfNeeded(self): if self.__isSorted__: return self.sortChildren() - @property + @property def FullTitle(self): return self.Title.FullTitle if self.Title else "" - + @property def Level(self): return self.Title.Level diff --git a/anknotes_remove_tags.py b/anknotes_remove_tags.py index 510bec8..02d9c14 100644 --- a/anknotes_remove_tags.py +++ b/anknotes_remove_tags.py @@ -2,7 +2,7 @@ import sys inAnki='anki' in sys.modules -if not inAnki: +if not inAnki: from anknotes.shared import * try: from pysqlite2 import dbapi2 as sqlite except ImportError: from sqlite3 import dbapi2 as sqlite diff --git a/anknotes_start_note_validation.py b/anknotes_start_note_validation.py index 22b10b8..9e2806c 100644 --- a/anknotes_start_note_validation.py +++ b/anknotes_start_note_validation.py @@ -1,10 +1,10 @@ import os from anknotes import stopwatch import time -try: +try: from lxml import etree eTreeImported=True -except: +except: eTreeImported=False if eTreeImported: @@ -13,9 +13,8 @@ except ImportError: from sqlite3 import dbapi2 as sqlite - ### Anknotes Module Imports for Stand Alone Scripts - from anknotes import evernote as evernote - + ### Anknotes Module Imports for Stand Alone Scripts + from anknotes import evernote as evernote ### Anknotes Shared Imports from anknotes.shared import * from anknotes.error import * @@ -38,8 +37,8 @@ # from anknotes.evernote.edam.type.ttypes import NoteSortOrder, Note as EvernoteNote from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException # from anknotes.evernote.api.client import EvernoteClient - - + + ankDBSetLocal() db = ankDB() db.Init() @@ -73,7 +72,7 @@ log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) log(result['contents'], 'MakeNoteQueue\\'+currentLog, timestamp=False) log("------------------------------------------------\n", 'MakeNoteQueue\\'+currentLog, timestamp=False) - + EN = Evernote() currentLog = 'Pending' @@ -86,34 +85,32 @@ guid = result['guid'] noteContents = result['contents'] noteTitle = result['title'] - line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' - - success, errors = EN.validateNoteContent(noteContents, noteTitle) + line = (" [%-30s] " % ((result['guid']) + ':')) if result['guid'] else "NEW [%-30s] " % '' + success, errors = EN.validateNoteContent(noteContents, noteTitle) validation_status = 1 if success else -1 - + line = " SUCCESS! " if success else " FAILURE: " line += ' ' if result['guid'] else ' NEW ' # line += ' %-60s ' % (result['title'] + ':') log_dump(errors, 'LXML ERRORS', 'lxml_errors', crosspost_to_default=False) - if not success: + if not success: if not isinstance(errors, unicode) and not isinstance(errors, str): - errors = '\n * ' + '\n * '.join(errors) + errors = '\n * ' + '\n * '.join(errors) log(line + errors, 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) else: if not isinstance(errors, unicode) and not isinstance(errors, str): errors = '\n'.join(errors) - - + + sql = "UPDATE %s SET validation_status = %d, validation_result = '%s' WHERE " % (TABLES.NOTE_VALIDATION_QUEUE, validation_status, escape_text_sql(errors)) if guid: sql += "guid = '%s'" % guid else: sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) - + db.execute(sql) - - timerFull.stop() + timerFull.stop() log("Validation of %d results completed in %s" % (len(pending_queued_items), str(timerFull)), 'MakeNoteQueue\\'+currentLog, timestamp=False, do_print=True) db.commit() From eea450c560710f42c640458ba1287d3923c4d712 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Tue, 29 Sep 2015 05:08:01 -0400 Subject: [PATCH 36/70] Fix unicode/string encoding issues --- anknotes/AnkiNotePrototype.py | 139 +++++++++++++++++++++++++--------- anknotes/EvernoteImporter.py | 10 +-- anknotes/constants.py | 1 + anknotes/structs.py | 7 +- 4 files changed, 112 insertions(+), 45 deletions(-) diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index 7eb3752..cacd301 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -34,8 +34,8 @@ class AnkiNotePrototype: """:type : list[str]""" ModelName = None """:type : str""" - Guid = "" - """:type : str""" + # Guid = "" + # """:type : str""" NotebookGuid = "" """:type : str""" __cloze_count__ = 0 @@ -52,6 +52,10 @@ class Counts: _unprocessed_see_also_ = "" _log_update_if_unchanged_ = True + @property + def Guid(self): + return get_evernote_guid_from_anki_fields(self.Fields) + def __init__(self, anki=None, fields=None, tags=None, base_note=None, notebookGuid=None, count=-1, count_update=0, max_count=1, counts=None, light_processing=False, enNote=None): """ @@ -89,14 +93,14 @@ def __init__(self, anki=None, fields=None, tags=None, base_note=None, notebookGu self.Counts.Current = count + 1 self.Counts.Max = max_count self.initialize_fields() - self.Guid = get_evernote_guid_from_anki_fields(self.Fields) + # self.Guid = get_evernote_guid_from_anki_fields(self.Fields) self.NotebookGuid = notebookGuid self.ModelName = None # MODELS.DEFAULT # self.Title = EvernoteNoteTitle() if not self.NotebookGuid and self.Anki: self.NotebookGuid = self.Anki.get_notebook_guid_from_ankdb(self.Guid) if not self.Guid and (self.light_processing or self.NotebookGuid): - log('Guid/Notebook Guid missing for: ' + self.Fields[FIELDS.TITLE]) + log('Guid/Notebook Guid missing for: ' + self.FullTitle) log(self.Guid) log(self.NotebookGuid) raise ValueError @@ -252,7 +256,7 @@ def step_6_process_see_also_links(): def detect_note_model(self): # log('Title, self.model_name, tags, self.model_name', 'detectnotemodel') - # log(self.Fields[FIELDS.TITLE], 'detectnotemodel') + # log(self.FullTitle, 'detectnotemodel') # log(self.ModelName, 'detectnotemodel') if FIELDS.CONTENT in self.Fields and "{{c1::" in self.Fields[FIELDS.CONTENT]: self.ModelName = MODELS.CLOZE @@ -318,9 +322,7 @@ def log_update(self, content=''): count_str += '%-4d]' % self.Counts.Max count_str += ' (%2d%%)' % (float(self.Counts.Current) / self.Counts.Max * 100) log_title = '!' if content else '' - log_title += 'UPDATING NOTE%s: %-80s: %s' % (count_str, self.Fields[FIELDS.TITLE], - self.Fields[FIELDS.EVERNOTE_GUID].replace( - FIELDS.EVERNOTE_GUID_PREFIX, '')) + log_title += 'UPDATING NOTE%s: %-80s: %s' % (count_str, self.FullTitle, self.Guid) log(log_title, 'AddUpdateNote', timestamp=(content is ''), clear=((self.Counts.Current == 1 or self.Counts.Current == 100) and not self.logged)) self.logged = True @@ -394,7 +396,7 @@ def update_note_fields(self): self.log_update(field_updates) log_error( "ERROR: UPDATE_NOTE: Note '%s': %s: Unable to set self.note.fields for field '%s'. Ord: %s. Note fields count: %d" % ( - self.Guid, self.Fields[FIELDS.TITLE], field_to_update, str(fld.get('ord')), + self.Guid, self.FullTitle, field_to_update, str(fld.get('ord')), len(self.note.fields))) raise for update in field_updates: @@ -428,8 +430,8 @@ def update_note(self): self.OriginalGuid = get_evernote_guid_from_anki_fields(flds) db_title = ankDB().scalar( "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.OriginalGuid)) - new_guid = self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') - new_title = self.Fields[FIELDS.TITLE] + new_guid = self.Guid + new_title = self.FullTitle self.check_titles_equal(db_title, new_title, new_guid) self.note.flush() self.update_note_model() @@ -452,6 +454,7 @@ def check_titles_equal(self, old_title, new_title, new_guid, log_title='DB INFO self.log_update(log_str) return False return True + @property def Title(self): """:rtype : EvernoteNoteTitle.EvernoteNoteTitle """ @@ -465,29 +468,78 @@ def Title(self): @property def FullTitle(self): return self.Title.FullTitle - - def add_note(self): - self.create_note() - if self.note is not None: - collection = self.Anki.collection() - db_title = ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % ( - TABLES.EVERNOTE.NOTES, self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, ''))) - log(' %s: ADD: ' % self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') + ' ' + - self.Fields[FIELDS.TITLE], 'AddUpdateNote') - self.check_titles_equal(db_title, self.Fields[FIELDS.TITLE], self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, 'NEW NOTE TITLE ')) + def save_anki_fields_decoded(self): + title = self.db_title if hasattr(self, 'db_title') else self.FullTitle + for key, value in enumerate(self.note.fields): + log('ANKI-->ANP-->SAVE FIELDS (DECODED)-->DECODING %s for field ' % str(type(value)) + key + ": " + title, 'unicode') + self.note.fields[key] = value.decode('utf-8') + return + for name, value in self.Fields.items(): try: - collection.addNote(self.note) + if isinstance(value, unicode): + action='ENCODED' + log('ANKI-->ANP-->SAVE FIELDS (DECODED)-->ENCODING UNICODE STRING for field ' + name, 'unicode') + self.note[name]=value.encode('utf-8') + else: + action='DECODED' + log('ANKI-->ANP-->SAVE FIELDS (DECODED)-->DECODING BYTE STRING for field ' + name, 'unicode') + self.note[name]=value.decode('utf-8') + except UnicodeDecodeError, e: + log_error("ANKI-->ANP-->SAVE FIELDS (DECODED) [%s] FAILED: UnicodeDecodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( + action, repr(e) + ": " + str(e), self.Guid, title, e.object, type(value))) + raise + except UnicodeEncodeError, e: + log_error("ANKI-->ANP-->SAVE FIELDS (DECODED) [%s] FAILED: UnicodeEncodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( + action, repr(e) + ": " + str(e), self.Guid, title, e.object, type(value))) + raise except Exception, e: - log_error("Unable to collection.addNote: \n - Error: %s\n - GUID: %s\n - Title: %s" % ( - str(type(e)) + ": " + str(e), self.Fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, ''), db_title)) - log_dump(self.note.fields, '- FAILED collection.addNote: ') + log_error("ANKI-->ANP-->SAVE FIELDS (DECODED) [%s] FAILED: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Type: %s" % ( + action, repr(e) + ": " + str(e), self.Guid, title, type(value))) + log_dump(self.note.fields, '- FAILED save_anki_fields_decoded: ', 'ANP') raise return -1 - collection.autosave() - self.Anki.start_editing() - return self.note.id - - def create_note(self): + + def add_note_try(self, attempt=1): + title = self.db_title if hasattr(self, 'db_title') else self.FullTitle + col = self.Anki.collection() + try: + col.addNote(self.note) + return 1 + except UnicodeDecodeError, e: + if attempt is 1: + self.save_anki_fields_decoded() + self.add_note_try(attempt+1) + else: + log(self.note.fields) + log_error("ANKI-->ANP-->ADD NOTE FAILED: UnicodeDecodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( + repr(e) + ": " + str(e), self.Guid, str_safe(title), str_safe(e.object), type(self.note[FIELDS.TITLE]))) + raise + except UnicodeEncodeError, e: + log_error("ANKI-->ANP-->ADD NOTE FAILED: UnicodeEncodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( + repr(e) + ": " + str(e), self.Guid, str_safe(title), str_safe(e.object), type(self.note[FIELDS.TITLE]))) + raise + except Exception, e: + if attempt > 1: raise + log_error("ANKI-->ANP-->ADD NOTE FAILED: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Type: %s" % ( + repr(e) + ": " + str(e), self.Guid, title, type(self.note[FIELDS.TITLE]))) + log_dump(self.note.fields, '- FAILED collection.addNote: ', 'ANP') + raise + return -1 + + def add_note(self): + self.create_note() + if self.note is None: return -1 + collection = self.Anki.collection() + db_title = ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % ( + TABLES.EVERNOTE.NOTES, self.Guid)) + log(' %s: ADD: ' % self.Guid + ' ' + self.FullTitle, 'AddUpdateNote') + self.check_titles_equal(db_title, self.FullTitle, self.Guid, 'NEW NOTE TITLE UNEQUAL TO DB ENTRY') + if self.add_note_try() is not 1: return -1 + collection.autosave() + self.Anki.start_editing() + return self.note.id + + def create_note(self,attempt=1): id_deck = self.Anki.decks().id(self.deck()) if not self.ModelName: self.ModelName = MODELS.DEFAULT model = self.Anki.models().byName(self.ModelName) @@ -495,14 +547,31 @@ def create_note(self): self.note = AnkiNote(col, model) self.note.model()['did'] = id_deck self.note.tags = self.Tags + title = self.db_title if hasattr(self, 'db_title') else self.FullTitle for name, value in self.Fields.items(): - try: self.note[name] = value #.encode('utf-8') + try: + if isinstance(value, unicode): + action='ENCODED' + log('ANKI-->ANP-->CREATE NOTE-->ENCODING UNICODE STRING for field ' + name, 'unicode') + self.note[name]=value.encode('utf-8') + else: + action='DECODED' + log('ANKI-->ANP-->CREATE NOTE-->DECODING BYTE STRING for field ' + name, 'unicode') + self.note[name]=value.decode('utf-8') except UnicodeEncodeError, e: - log_error('Create Note Error: %s: %s\n - Message: %s' % (str(type(e)), str(type(value)), str(e))) - raise + log_error("ANKI-->ANP-->CREATE NOTE-->SAVE NOTE FIELD '%s' (%s) FAILED: UnicodeEncodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( + name, action, repr(e) + ": " + str(e), self.Guid, title, e.object, type(value))) + try: self.note[name] = value.encode('utf-8') + except Exception, e: + log_error("ANKI-->ANP-->CREATE NOTE-->SAVE NOTE FIELD '%s' (%s) FAILED: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Type: %s" % ( + name, action,repr(e) + ": " + str(e), self.Guid, title, type(value))) + raise + # raise except UnicodeDecodeError, e: - log_error('Create Note Error: %s: %s\n - Message: %s' % (str(type(e)), str(type(value)), str(e))) + log_error("ANKI-->ANP-->CREATE NOTE-->SAVE NOTE FIELD '%s' (%s) FAILED: UnicodeDecodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( + name, action,repr(e) + ": " + str(e), self.Guid, title, e.object, type(value))) raise except Exception, e: - log_error('Create Note Error: %s: %s\n - Message: %s' % (str(type(e)), str(type(value)), str(e))) + log_error("ANKI-->ANP-->CREATE NOTE-->SAVE NOTE FIELD '%s' (%s) FAILED: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Type: %s" % ( + name, action,repr(e) + ": " + str(e), self.Guid, title, type(value))) raise diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index f0a393d..6279ea1 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -56,7 +56,7 @@ def override_evernote_metadata(self): guids = self.ManualGUIDs self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) self.MetadataProgress.Total = len(guids) - self.MetadataProgress.Current = min(self.MetadataProgress.Total - self.MetadataProgress.Offset, 250) + self.MetadataProgress.Current = min(self.MetadataProgress.Total - self.MetadataProgress.Offset, EVERNOTE.IMPORT.QUERY_LIMIT) result = NotesMetadataList() result.totalNotes = len(guids) result.updateCount = -1 @@ -172,8 +172,8 @@ def proceed_start(self, auto_paging=False): col.save() lastImportStr = get_friendly_interval_string(lastImport) if lastImportStr: lastImportStr = ' [LAST IMPORT: %s]' % lastImportStr - log("! > Starting Evernote Import: Page #%d: %-60s%s" % ( - self.currentPage, settings.generate_evernote_query(), lastImportStr)) + log("! > Starting Evernote Import: Page %3s Query: %s".ljust(123) % ( + '#' + str(self.currentPage), settings.generate_evernote_query()) + ' ' + lastImportStr) log("-"*186, timestamp=False) if not auto_paging: note_store_status = self.evernote.initialize_note_store() @@ -186,10 +186,6 @@ def proceed_start(self, auto_paging=False): def proceed_find_metadata(self, auto_paging=False): global latestEDAMRateLimit, latestSocketError - - # anki_note_ids = self.anki.get_anknotes_note_ids() - # anki_evernote_guids = self.anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) - if self.ManualMetadataMode: self.override_evernote_metadata() else: diff --git a/anknotes/constants.py b/anknotes/constants.py index 5d4e56a..2a36e25 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -119,6 +119,7 @@ class RESTART: RESTART.INTERVAL = INTERVAL * 2 INTERVAL = PAGING.INTERVAL * 4 / 3 METADATA_RESULTS_LIMIT = 10000 + QUERY_LIMIT = 250 # Max returned by API is 250 API_CALLS_LIMIT = 300 class UPLOAD: ENABLED = True # Set False if debugging note creation diff --git a/anknotes/structs.py b/anknotes/structs.py index 058dadb..b862832 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -662,14 +662,15 @@ def SummaryList(self): def Summary(self): return JoinList(self.SummaryList, ['\n', ' | '], 31) @property - def QueryMax(self): return 250 + def QueryLimit(self): return EVERNOTE.IMPORT.QUERY_LIMIT + @property - def Offset(self): return (self.Page - 1) * self.QueryMax + def Offset(self): return (self.Page - 1) * self.QueryLimit @property def TotalPages(self): if self.Total is -1: return -1 - p = float(self.Total) / self.QueryMax + p = float(self.Total) / self.QueryLimit return int(p) + (1 if p > int(p) else 0) @property From 45f427444196a260be5860cd04f648d5a336f0d1 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 30 Sep 2015 01:04:27 -0400 Subject: [PATCH 37/70] Major improvements to Unicode error handling --- anknotes/Anki.py | 26 +++-- anknotes/AnkiNotePrototype.py | 153 ++++++++++++----------------- anknotes/EvernoteImporter.py | 61 ++++++------ anknotes/EvernoteNoteFetcher.py | 9 +- anknotes/EvernoteNotes.py | 4 +- anknotes/__main__.py | 28 +++--- anknotes/ankEvernote.py | 34 ++++--- anknotes/constants.py | 11 +++ anknotes/counters.py | 9 +- anknotes/error.py | 36 ++++++- anknotes/logging.py | 166 ++++++++++++++++---------------- anknotes/menu.py | 7 +- anknotes/shared.py | 2 +- anknotes/structs.py | 4 +- 14 files changed, 295 insertions(+), 255 deletions(-) diff --git a/anknotes/Anki.py b/anknotes/Anki.py index f32f46e..67ffdd5 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -79,9 +79,11 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang :return: Count of notes successfully added or updated """ count_update = 0 - tmr = stopwatch.Timer(len(evernote_notes), 100, label='AddEvernoteNotes', display_initial_info=False) - if tmr.willReportProgress: - log_banner(['ADDING', 'UPDATING'][update] + " %d EVERNOTE NOTES %s ANKI" % (tmr.counts.max.val, ['TO', 'IN'][update]), tmr.label, append_newline=False) + action_str = ['ADDING', 'UPDATING'][update] + tmr = stopwatch.Timer(len(evernote_notes), 100, infoStr=action_str + " EVERNOTE NOTE(S) %s ANKI" % ['TO', 'IN'][update], label='AddEvernoteNotes') + + # if tmr.willReportProgress: + # log_banner(), tmr.label, append_newline=False) for ankiNote in evernote_notes: try: title = ankiNote.FullTitle @@ -98,7 +100,7 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang except: log_error("Unable to set field info for: Note '%s': '%s'" % (ankiNote.Title, ankiNote.Guid)) log_dump(ankiNote.Content, " NOTE CONTENTS ") - log_dump(ankiNote.Content.encode('utf-8'), " NOTE CONTENTS ") + # log_dump(ankiNote.Content.encode('utf-8'), " NOTE CONTENTS ") raise if tmr.step(): log(['Adding', 'Updating'][update] + " Note %5s: %s: %s" % ('#' + str(tmr.count), tmr.progress, title), tmr.label) @@ -117,7 +119,12 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang notebookGuid=ankiNote.NotebookGuid, count=tmr.count, count_update=tmr.counts.success, max_count=tmr.counts.max.val) anki_note_prototype._log_update_if_unchanged_ = log_update_if_unchanged - if (update and anki_note_prototype.update_note()) or (not update and anki_note_prototype.add_note() != -1): tmr.reportSuccess() + anki_result = anki_note_prototype.update_note() if update else anki_note_prototype.add_note() + if anki_result != -1: tmr.reportSuccess(update, True) + else: + tmr.reportError(True) + log("ANKI ERROR WHILE %s EVERNOTE NOTES: " % action_str + str(anki_result), 'AddEvernoteNotes-Error') + tmr.Report() return tmr.counts.success def delete_anki_cards(self, evernote_guids): @@ -381,19 +388,24 @@ def insert_toc_into_see_also(self): if key not in grouped_results: grouped_results[key] = [row[3], []] grouped_results[key][1].append(value) # log_dump(grouped_results, 'grouped_results', 'insert_toc', timestamp=False) - log.banner('INSERT TOCS INTO ANKI NOTES: %d NOTES' % len(grouped_results), 'insert_toc') + action_title = 'INSERT TOCS INTO ANKI NOTES' + log.banner(action_title + ': %d NOTES' % len(grouped_results), 'insert_toc') toc_separator = generate_evernote_span(u' | ', u'Links', u'See Also', bold=False) count = 0 count_update = 0 max_count = len(grouped_results) log.add(' <h1>INSERT TOC LINKS INTO ANKI NOTES: %d TOTAL NOTES</h1> <HR><BR><BR>' % max_count, 'see_also', timestamp=False, clear=True, extension='htm') + logged_missing_anki_note=False for source_guid, source_guid_info in sorted(grouped_results.items(), key=lambda s: s[1][0]): toc_guids = source_guid_info[1] note_title = source_guid_info[0] ankiNote = self.get_anki_note_from_evernote_guid(source_guid) if not ankiNote: - log.dump(toc_guids, 'Missing Anki Note for ' + source_guid, 'insert_toc', timestamp=False) + log.dump(toc_guids, 'Missing Anki Note for ' + source_guid, 'insert_toc', timestamp=False, crosspost_to_default=False) + if not logged_missing_anki_note: + log_error('ERROR DURING %s: Missing Anki Note(s) for TOC entry. See insert_toc log for more details' % action_title) + logged_missing_anki_note = True else: fields = get_dict_from_list(ankiNote.items()) see_also_html = fields[FIELDS.SEE_ALSO] diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index cacd301..b1aa919 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- ### Anknotes Shared Imports from anknotes.shared import * +from anknotes.error import HandleUnicodeError from anknotes.EvernoteNoteTitle import EvernoteNoteTitle ### Anki Imports @@ -217,9 +218,15 @@ def step_6_process_see_also_links(): ################################### Step 6: Process "See Also: " Links see_also_match = regex_see_also().search(self.Fields[FIELDS.CONTENT]) if not see_also_match: - if self.Fields[FIELDS.CONTENT].find("See Also") > -1: - log("No See Also Content Found, but phrase 'See Also' exists in " + self.FullTitle + " \n" + self.Fields[FIELDS.CONTENT]) - raise ValueError + i_see_also = self.Fields[FIELDS.CONTENT].find("See Also") + if i_see_also > -1: + self.loggedSeeAlsoError = self.Guid + i_div = self.Fields[FIELDS.CONTENT].rfind("<div", 0, i_see_also) + if i_div is -1: i_div = i_see_also + log_error("No See Also Content Found, but phrase 'See Also' exists in " + self.Guid + ": " + self.FullTitle, crosspost_to_default=False) + log("No See Also Content Found, but phrase 'See Also' exists: \n" + self.Guid + ": " + self.FullTitle + " \n" + self.Fields[FIELDS.CONTENT][i_div:i_see_also+50] + '\n', 'SeeAlso\\MatchExpected') + log(self.Fields[FIELDS.CONTENT], 'SeeAlso\\MatchExpected\\'+self.FullTitle) + # raise ValueError return self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group(0), see_also_match.group('Suffix')) self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<div><b><br/></b></div></en-note>', '</en-note>') @@ -409,22 +416,28 @@ def update_note(self): self.logged = False if not self.BaseNote: self.log_update("Not updating Note: Could not find base note") - return False + return -1 self.Changed = False self.update_note_tags() - self.update_note_fields() - if 'See Also' in self.Fields[FIELDS.CONTENT]: - raise ValueError + self.update_note_fields() + i_see_also = self.Fields[FIELDS.CONTENT].find("See Also") + if i_see_also > -1: + i_div = self.Fields[FIELDS.CONTENT].rfind("<div", 0, i_see_also) + if i_div is -1: i_div = i_see_also + if not hasattr(self, 'loggedSeeAlsoError') or self.loggedSeeAlsoError != self.Guid: + log_error("No See Also Content Found, but phrase 'See Also' exists in " + self.Guid + ": " + self.FullTitle, crosspost_to_default=False) + log("No See Also Content Found, but phrase 'See Also' exists: \n" + self.Guid + ": " + self.FullTitle + " \n" + self.Fields[FIELDS.CONTENT][i_div:i_see_also+50] + '\n', 'SeeAlso\\MatchExpectedUpdate') + log(self.Fields[FIELDS.CONTENT], 'SeeAlso\\MatchExpectedUpdate\\'+self.FullTitle) if not (self.Changed or self.update_note_deck()): if self._log_update_if_unchanged_: self.log_update("Not updating Note: The fields, tags, and deck are the same") elif (self.Counts.Updated is 0 or self.Counts.Current / self.Counts.Updated > 9) and self.Counts.Current % 100 is 0: self.log_update() - return False + return 0 if not self.Changed: # i.e., the note deck has been changed but the tags and fields have not self.Counts.Updated += 1 - return True + return 1 if not self.OriginalGuid: flds = get_dict_from_list(self.BaseNote.items()) self.OriginalGuid = get_evernote_guid_from_anki_fields(flds) @@ -436,7 +449,7 @@ def update_note(self): self.note.flush() self.update_note_model() self.Counts.Updated += 1 - return True + return 1 def check_titles_equal(self, old_title, new_title, new_guid, log_title='DB INFO UNEQUAL'): @@ -468,70 +481,52 @@ def Title(self): @property def FullTitle(self): return self.Title.FullTitle - def save_anki_fields_decoded(self): - title = self.db_title if hasattr(self, 'db_title') else self.FullTitle - for key, value in enumerate(self.note.fields): - log('ANKI-->ANP-->SAVE FIELDS (DECODED)-->DECODING %s for field ' % str(type(value)) + key + ": " + title, 'unicode') - self.note.fields[key] = value.decode('utf-8') - return - for name, value in self.Fields.items(): - try: - if isinstance(value, unicode): - action='ENCODED' - log('ANKI-->ANP-->SAVE FIELDS (DECODED)-->ENCODING UNICODE STRING for field ' + name, 'unicode') - self.note[name]=value.encode('utf-8') - else: - action='DECODED' - log('ANKI-->ANP-->SAVE FIELDS (DECODED)-->DECODING BYTE STRING for field ' + name, 'unicode') - self.note[name]=value.decode('utf-8') - except UnicodeDecodeError, e: - log_error("ANKI-->ANP-->SAVE FIELDS (DECODED) [%s] FAILED: UnicodeDecodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( - action, repr(e) + ": " + str(e), self.Guid, title, e.object, type(value))) - raise - except UnicodeEncodeError, e: - log_error("ANKI-->ANP-->SAVE FIELDS (DECODED) [%s] FAILED: UnicodeEncodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( - action, repr(e) + ": " + str(e), self.Guid, title, e.object, type(value))) - raise - except Exception, e: - log_error("ANKI-->ANP-->SAVE FIELDS (DECODED) [%s] FAILED: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Type: %s" % ( - action, repr(e) + ": " + str(e), self.Guid, title, type(value))) - log_dump(self.note.fields, '- FAILED save_anki_fields_decoded: ', 'ANP') - raise - return -1 - + def save_anki_fields_decoded(self, attempt, from_anp_fields=False, do_decode=None): + title = self.db_title if hasattr(self, 'db_title') else self.FullTitle + e_return=False + log_header='ANKI-->ANP-->' + if from_anp_fields: + log_header += 'CREATE ANKI FIELDS' + base_values = self.Fields.items() + else: + log_header += 'SAVE ANKI FIELDS (DECODED)' + base_values = enumerate(self.note.fields) + for key, value in base_values: + name = key if from_anp_fields else FIELDS.LIST[key - 1] if key > 0 else FIELDS.EVERNOTE_GUID + if isinstance(value, unicode) and not do_decode is True: action='ENCODING' + elif isinstance(value, str) and not do_decode is False: action='DECODING' + else: action='DOING NOTHING' + log('\t - %s for %s field %s' % (action, value.__class__.__name__, name), 'unicode', timestamp=False) + if action is not 'DOING NOTHING': + try: + new_value = value.encode('utf-8') if action=='ENCODED' else value.decode('utf-8') + if from_anp_fields: self.note[key] = new_value + else: self.note.fields[key] = new_value + except (UnicodeDecodeError, UnicodeEncodeError, UnicodeTranslateError, UnicodeError, Exception), e: + e_return = HandleUnicodeError(log_header, e, self.Guid, title, action, attempt, value, field=name) + if e_return is not 1: raise + if e_return is not False: log_blank('unicode') + return 1 + def add_note_try(self, attempt=1): title = self.db_title if hasattr(self, 'db_title') else self.FullTitle col = self.Anki.collection() - try: + log_header = 'ANKI-->ANP-->ADD NOTE FAILED' + action = 'DECODING?' + try: col.addNote(self.note) - return 1 - except UnicodeDecodeError, e: - if attempt is 1: - self.save_anki_fields_decoded() - self.add_note_try(attempt+1) - else: - log(self.note.fields) - log_error("ANKI-->ANP-->ADD NOTE FAILED: UnicodeDecodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( - repr(e) + ": " + str(e), self.Guid, str_safe(title), str_safe(e.object), type(self.note[FIELDS.TITLE]))) - raise - except UnicodeEncodeError, e: - log_error("ANKI-->ANP-->ADD NOTE FAILED: UnicodeEncodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( - repr(e) + ": " + str(e), self.Guid, str_safe(title), str_safe(e.object), type(self.note[FIELDS.TITLE]))) - raise - except Exception, e: - if attempt > 1: raise - log_error("ANKI-->ANP-->ADD NOTE FAILED: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Type: %s" % ( - repr(e) + ": " + str(e), self.Guid, title, type(self.note[FIELDS.TITLE]))) - log_dump(self.note.fields, '- FAILED collection.addNote: ', 'ANP') - raise - return -1 + except (UnicodeDecodeError, UnicodeEncodeError, UnicodeTranslateError, UnicodeError, Exception), e: + e_return = HandleUnicodeError(log_header, e, self.Guid, title, action, attempt, self.note[FIELDS.TITLE]) + if e_return is not 1: raise + self.save_anki_fields_decoded(attempt+1) + return self.add_note_try(attempt+1) + return 1 def add_note(self): self.create_note() if self.note is None: return -1 collection = self.Anki.collection() - db_title = ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % ( - TABLES.EVERNOTE.NOTES, self.Guid)) + db_title = ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.Guid)) log(' %s: ADD: ' % self.Guid + ' ' + self.FullTitle, 'AddUpdateNote') self.check_titles_equal(db_title, self.FullTitle, self.Guid, 'NEW NOTE TITLE UNEQUAL TO DB ENTRY') if self.add_note_try() is not 1: return -1 @@ -548,30 +543,4 @@ def create_note(self,attempt=1): self.note.model()['did'] = id_deck self.note.tags = self.Tags title = self.db_title if hasattr(self, 'db_title') else self.FullTitle - for name, value in self.Fields.items(): - try: - if isinstance(value, unicode): - action='ENCODED' - log('ANKI-->ANP-->CREATE NOTE-->ENCODING UNICODE STRING for field ' + name, 'unicode') - self.note[name]=value.encode('utf-8') - else: - action='DECODED' - log('ANKI-->ANP-->CREATE NOTE-->DECODING BYTE STRING for field ' + name, 'unicode') - self.note[name]=value.decode('utf-8') - except UnicodeEncodeError, e: - log_error("ANKI-->ANP-->CREATE NOTE-->SAVE NOTE FIELD '%s' (%s) FAILED: UnicodeEncodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( - name, action, repr(e) + ": " + str(e), self.Guid, title, e.object, type(value))) - try: self.note[name] = value.encode('utf-8') - except Exception, e: - log_error("ANKI-->ANP-->CREATE NOTE-->SAVE NOTE FIELD '%s' (%s) FAILED: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Type: %s" % ( - name, action,repr(e) + ": " + str(e), self.Guid, title, type(value))) - raise - # raise - except UnicodeDecodeError, e: - log_error("ANKI-->ANP-->CREATE NOTE-->SAVE NOTE FIELD '%s' (%s) FAILED: UnicodeDecodeError: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Object: %s\n - Type: %s" % ( - name, action,repr(e) + ": " + str(e), self.Guid, title, e.object, type(value))) - raise - except Exception, e: - log_error("ANKI-->ANP-->CREATE NOTE-->SAVE NOTE FIELD '%s' (%s) FAILED: \n - Error: %s\n - GUID: %s\n - Title: %s\n - Type: %s" % ( - name, action,repr(e) + ": " + str(e), self.Guid, title, type(value))) - raise + self.save_anki_fields_decoded(attempt, True, True) \ No newline at end of file diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index 6279ea1..2216441 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -79,7 +79,10 @@ def get_evernote_metadata(self): self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) spec = NotesMetadataResultSpec(includeTitle=False, includeUpdated=False, includeUpdateSequenceNum=True, includeTagGuids=True, includeNotebookGuid=True) - self.evernote.initialize_note_store() + notestore_status = self.initialize_note_store() + if not notestore_status.IsSuccess: + self.MetadataProgress.Status = notestore_status + return False # notestore_status api_action_str = u'trying to search for note metadata' log_api("findNotesMetadata", "[Offset: %3d]: Query: '%s'" % (self.MetadataProgress.Offset, query)) try: @@ -99,7 +102,7 @@ def get_evernote_metadata(self): return False self.MetadataProgress.loadResults(result) self.evernote.metadata = self.MetadataProgress.NotesMetadata - log(self.MetadataProgress.Summary, line_padding_header="- Metadata Results: ", line_padding=32, timestamp=False) + log(self.MetadataProgress.Summary, line_padding_header="- Metadata Results: ", line_padding=ANKNOTES.FORMATTING.LINE_PADDING_HEADER, timestamp=False) return True def update_in_anki(self, evernote_guids): @@ -172,24 +175,26 @@ def proceed_start(self, auto_paging=False): col.save() lastImportStr = get_friendly_interval_string(lastImport) if lastImportStr: lastImportStr = ' [LAST IMPORT: %s]' % lastImportStr - log("! > Starting Evernote Import: Page %3s Query: %s".ljust(123) % ( - '#' + str(self.currentPage), settings.generate_evernote_query()) + ' ' + lastImportStr) - log("-"*186, timestamp=False) - if not auto_paging: - note_store_status = self.evernote.initialize_note_store() - if not note_store_status == EvernoteAPIStatus.Success: - log(" > Note store does not exist. Aborting.") - show_tooltip("Could not connect to Evernote servers (Status Code: %s)... Aborting." % note_store_status.name) - return False - self.evernote.getNoteCount = 0 + log_banner(" > Starting Evernote Import: Page %3s Query: %s".ljust(122) % ( + '#' + str(self.currentPage), settings.generate_evernote_query()) + lastImportStr, append_newline=False, chr='=', length=0, center=False, clear=False, timestamp=True) + # log("! > Starting Evernote Import: Page %3s Query: %s".ljust(123) % ( + # '#' + str(self.currentPage), settings.generate_evernote_query()) + ' ' + lastImportStr) + # log("-"*(ANKNOTES.FORMATTING.LINE_LENGTH+1), timestamp=False) + if auto_paging: return True + notestore_status = self.evernote.initialize_note_store() + if not notestore_status == EvernoteAPIStatus.Success: + log(" > Note store does not exist. Aborting.") + show_tooltip("Could not connect to Evernote servers (Status Code: %s)... Aborting." % notestore_status.name) + return False + self.evernote.getNoteCount = 0 + return True def proceed_find_metadata(self, auto_paging=False): global latestEDAMRateLimit, latestSocketError - if self.ManualMetadataMode: - self.override_evernote_metadata() - else: - self.get_evernote_metadata() + if self.ManualMetadataMode: self.override_evernote_metadata() + else: self.get_evernote_metadata() + if self.MetadataProgress.Status == EvernoteAPIStatus.RateLimitError: m, s = divmod(latestEDAMRateLimit, 60) show_report(" > Error: Delaying Operation", @@ -207,20 +212,16 @@ def proceed_find_metadata(self, auto_paging=False): self.ImportProgress = EvernoteImportProgress(self.anki, self.MetadataProgress) self.ImportProgress.loadAlreadyUpdated( [] if self.ManualMetadataMode else self.check_note_sync_status(self.ImportProgress.GUIDs.Server.Existing.All)) - # log(self.MetadataProgress.Summary, line_padding_header="- Metadata Results: ", line_padding=32, timestamp=False) - log(self.ImportProgress.Summary + "\n", line_padding_header="- Note Sync Status: ", line_padding=32, timestamp=False) - # log(" " * 32 + "- " + self.ImportProgress.Summary + "\n", timestamp=False) + log(self.ImportProgress.Summary + "\n", line_padding_header="- Note Sync Status: ", line_padding=ANKNOTES.FORMATTING.LINE_PADDING_HEADER, timestamp=False) def proceed_import_notes(self): self.anki.start_editing() self.ImportProgress.processResults(self.import_into_anki(self.ImportProgress.GUIDs.Server.New)) - if self.updateExistingNotes is UpdateExistingNotes.UpdateNotesInPlace: - self.ImportProgress.processUpdateInPlaceResults( - self.update_in_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) - elif self.updateExistingNotes is UpdateExistingNotes.DeleteAndReAddNotes: + if self.updateExistingNotes == UpdateExistingNotes.UpdateNotesInPlace: + self.ImportProgress.processUpdateInPlaceResults(self.update_in_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) + elif self.updateExistingNotes == UpdateExistingNotes.DeleteAndReAddNotes: self.anki.delete_anki_cards(self.ImportProgress.GUIDs.Server.Existing.OutOfDate) - self.ImportProgress.processDeleteAndUpdateResults( - self.import_into_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) + self.ImportProgress.processDeleteAndUpdateResults(self.import_into_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) show_report(" > Import Complete", self.ImportProgress.ResultsSummaryLines) self.anki.stop_editing() self.anki.collection().autosave() @@ -231,6 +232,7 @@ def save_current_page(self): col.conf[SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE] = self.currentPage col.setMod() col.save() + def proceed_autopage(self): if not self.autoPagingEnabled: return @@ -276,19 +278,18 @@ def proceed_autopage(self): self.currentPage = self.MetadataProgress.Page + 1 restart_title = " > Continuing Auto Paging" restart_msg = "Page %d completed<BR>%d notes remain over %d page%s<BR>%d of %d notes have been processed" % ( - self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.RemainingPages, 's' if self.MetadataProgress.RemainingPages > 1 else '', self.MetadataProgress.Completed, - self.MetadataProgress.Total) + self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.RemainingPages, 's' if self.MetadataProgress.RemainingPages > 1 else '', self.MetadataProgress.Completed, self.MetadataProgress.Total) restart = -1 * max(30, EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL_OVERRIDE) if self.forceAutoPage: - suffix = "<BR>Only delaying {interval} as the forceAutoPage flag is set\n" + suffix = "<BR>Only delaying {interval} as the forceAutoPage flag is set" elif self.ImportProgress.APICallCount < EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS: - suffix = "<BR>Only delaying {interval} as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS\n" % ( + suffix = "<BR>Only delaying {interval} as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS" % ( self.ImportProgress.APICallCount, EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS) else: restart = max(EVERNOTE.IMPORT.PAGING.INTERVAL_SANDBOX,60*5) if EVERNOTE.API.IS_SANDBOXED else max(EVERNOTE.IMPORT.PAGING.INTERVAL, 60*10) suffix = "<BR>Delaying Auto Paging: Per EVERNOTE.IMPORT.PAGING.INTERVAL, " self.save_current_page() - if restart > 0: suffix += "will delay for {interval} before continuing\n" + if restart > 0: suffix += "will delay for {interval} before continuing" m, s = divmod(abs(restart), 60) suffix = suffix.format(interval=['%2ds' % s,'%d:%02d min'%(m,s)][m>0]) show_report(restart_title, (restart_msg + suffix).split('<BR>'), delay=5) diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index e17a56c..cc0463c 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -69,7 +69,7 @@ def getNoteLocal(self): """:type : sqlite.Row""" if not db_note: return False if not self.use_local_db_only: - log(' '*20 + "> getNoteLocal: GUID: '%s': %-40s" % (self.evernote_guid, db_note['title']), 'api') + log(' ' + '-'*14 + ' '*5 + "> getNoteLocal: %s" % db_note['title'], 'api') assert db_note['guid'] == self.evernote_guid self.reportSuccess(EvernoteNotePrototype(db_note=db_note), 1) self.setNoteTags(tag_names=self.result.Note.TagNames) @@ -127,10 +127,13 @@ def addNoteFromServerToDB(self, whole_note=None, tag_names=None): ankDB().commit() def getNoteRemoteAPICall(self): - self.evernote.initialize_note_store() + notestore_status = self.evernote.initialize_note_store() + if not notestore_status.IsSuccess: + self.reportResult(notestore_status) + return False api_action_str = u'trying to retrieve a note. We will save the notes downloaded thus far.' self.api_calls += 1 - log_api(" > getNote [%3d]" % self.api_calls, "GUID: '%s'" % self.evernote_guid) + log_api(" > getNote [%3d]" % self.api_calls, self.evernote_guid) try: self.whole_note = self.evernote.noteStore.getNote(self.evernote.token, self.evernote_guid, True, False, False, False) diff --git a/anknotes/EvernoteNotes.py b/anknotes/EvernoteNotes.py index a0d1290..82be22d 100644 --- a/anknotes/EvernoteNotes.py +++ b/anknotes/EvernoteNotes.py @@ -160,7 +160,9 @@ def processNote(self, enNote): childBaseTitleStr = enNote.Title.Base.FullTitle if childBaseTitleStr in self.RootNotesMissing.ChildTitlesDict[rootTitleStr]: log_error("Duplicate Child Base Title String. \n%-18s%s\n%-18s%s: %s\n%-18s%s" % ('Root Note Title: ', rootTitleStr, 'Child Note: ', enNote.Guid, childBaseTitleStr, 'Duplicate Note: ', self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitleStr]), crosspost_to_default=False) - log(" > WARNING: Duplicate Child Notes: " + enNote.FullTitle) + if not hasattr(self, 'loggedDuplicateChildNotesWarning'): + log(" > WARNING: Duplicate Child Notes found when processing Root Notes. See error log for more details") + self.loggedDuplicateChildNotesWarning = True self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitleStr] = enNote.Guid self.RootNotesMissing.ChildNotesDict[rootTitleStr][enNote.Guid] = enNote if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: diff --git a/anknotes/__main__.py b/anknotes/__main__.py index a800840..8cd4077 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -32,20 +32,20 @@ def import_timer_toggle(): title = "&Enable Auto Import On Profile Load" doAutoImport = mw.col.conf.get( SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False) - if doAutoImport: - lastImport = mw.col.conf.get(SETTINGS.EVERNOTE.LAST_IMPORT, None) - importDelay = 0 - if lastImport: - td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) - minimum = timedelta(seconds=max(EVERNOTE.IMPORT.INTERVAL, 20*60)) - if td < minimum: - importDelay = (minimum - td).total_seconds() * 1000 - if importDelay is 0: - menu.import_from_evernote() - else: - m, s = divmod(importDelay / 1000, 60) - log("> Starting Auto Import, Triggered by Profile Load, in %d:%02d min" % (m, s)) - mw.progress.timer(importDelay, menu.import_from_evernote, False) + if not doAutoImport: return + lastImport = mw.col.conf.get(SETTINGS.EVERNOTE.LAST_IMPORT, None) + importDelay = 0 + if lastImport: + td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) + minimum = timedelta(seconds=max(EVERNOTE.IMPORT.INTERVAL, 20*60)) + if td < minimum: + importDelay = (minimum - td).total_seconds() * 1000 + if importDelay is 0: + menu.import_from_evernote() + else: + m, s = divmod(importDelay / 1000, 60) + log("> Starting Auto Import, Triggered by Profile Load, in %d:%02d min" % (m, s)) + mw.progress.timer(importDelay, menu.import_from_evernote, False) def _findEdited((val, args)): diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index a04eeeb..a14f083 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -255,8 +255,9 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot return EvernoteAPIStatus.UserError, None ourNote.content = nBody - self.initialize_note_store() - + notestore_status = self.initialize_note_store() + if not notestore_status.IsSuccess: return notestore_status, None + while '' in tagNames: tagNames.remove('') if len(tagNames) > 0: if EVERNOTE.API.IS_SANDBOXED and not '#Sandbox' in tagNames: @@ -358,14 +359,20 @@ def update_ancillary_data(self): self.report_ancillary_data_results(new_tags, new_nbs, 'Manual ', report_blank=True) @staticmethod - def report_ancillary_data_results(new_tags, new_nbs, title_prefix='', report_blank=False): + def report_ancillary_data_results(new_tags, new_nbs, title_prefix='', report_blank=False): + strr = '' if new_tags is 0 and new_nbs is 0: if not report_blank: return - strr = 'No new tags or notebooks found' - elif new_tags is 0: strr = '%d new notebook%s found' % (new_nbs, '' if new_nbs is 1 else 's') - elif new_nbs is 0: strr = '%d new tag%s found' % (new_tags, '' if new_tags is 1 else 's') - else: strr = '%d new tag%s and %d new notebook%s found' % (new_tags, '' if new_tags is 1 else 's', new_nbs, '' if new_nbs is 1 else 's') + strr = 'No new tags or notebooks found' + elif new_tags is None and new_nbs is None: strr = 'Error downloading ancillary data' + elif new_tags is None: strr = 'Error downloading tags list, and ' + elif new_nbs is None: strr = 'Error downloading notebooks list, and ' + + if new_tags > 0 and new_nbs > 0: strr = '%d new tag%s and %d new notebook%s found' % (new_tags, '' if new_tags is 1 else 's', new_nbs, '' if new_nbs is 1 else 's') + elif new_nbs > 0: strr += '%d new notebook%s found' % (new_nbs, '' if new_nbs is 1 else 's') + elif new_tags > 0: strr += '%d new tag%s found' % (new_tags, '' if new_tags is 1 else 's') show_tooltip("%sUpdate of ancillary data complete: " % title_prefix + strr, do_log=True) + def set_notebook_data(self): if not hasattr(self, 'notebook_data') or not self.notebook_data or len(self.notebook_data.keys()) == 0: self.notebook_data = {x['guid']: EvernoteNotebook(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS)} @@ -403,7 +410,8 @@ def check_notebooks_up_to_date(self): return True def update_notebooks_database(self): - self.initialize_note_store() + notestore_status = self.initialize_note_store() + if not notestore_status.IsSuccess: return None # notestore_status api_action_str = u'trying to update Evernote notebooks.' log_api("listNotebooks") try: @@ -432,23 +440,23 @@ def update_notebooks_database(self): db.commit() # log_dump(ankDB().all("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS), 'sql data', crosspost_to_default=False) return len(self.notebook_data) - old_count + def update_tags_database(self, reason_str=''): if hasattr(self, 'LastTagDBUpdate') and datetime.now() - self.LastTagDBUpdate < timedelta(minutes=15): return None self.LastTagDBUpdate = datetime.now() - self.initialize_note_store() + notestore_status = self.initialize_note_store() + if not notestore_status.IsSuccess: return None # notestore_status api_action_str = u'trying to update Evernote tags.' log_api("listTags" + (': ' + reason_str) if reason_str else '') try: tags = self.noteStore.listTags(self.token) """: type : list[evernote.edam.type.ttypes.Tag] """ except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str): raise - if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return None except socket.error, v: - if not HandleSocketError(v, api_action_str): raise - if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return None data = [] self.tag_data = {} diff --git a/anknotes/constants.py b/anknotes/constants.py index 2a36e25..2d29876 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -54,6 +54,16 @@ class DEVELOPER_MODE: AUTO_RELOAD_MODULES = True class HIERARCHY: ROOT_TITLES_BASE_QUERY = "notebookGuid != 'fdccbccf-ee70-4069-a587-82772a96d9d3'" + class FORMATTING: + BANNER_MINIMUM = 80 + COUNTER_BANNER_MINIMUM = 40 + LINE_PADDING_HEADER = 31 + LINE_LENGTH = 185 + LIST_PAD = 25 + PROGRESS_SUMMARY_PAD = 31 + PPRINT_WIDTH = 80 + TIMESTAMP_PAD = '\t'*6 + TIMESTAMP_PAD_LENGTH = len(TIMESTAMP_PAD.replace('\t', ' '*4)) class MODELS: class TYPES: @@ -104,6 +114,7 @@ class TAGS: OUTLINE_TESTABLE = '#Outline.Testable' REVERSIBLE = '#Reversible' REVERSE_ONLY = '#Reversible_Only' + class EVERNOTE: class IMPORT: class PAGING: diff --git a/anknotes/counters.py b/anknotes/counters.py index 7e19433..61dba27 100644 --- a/anknotes/counters.py +++ b/anknotes/counters.py @@ -2,12 +2,13 @@ import sys from pprint import pprint from addict import Dict +from anknotes.constants import * inAnki='anki' in sys.modules def print_banner(title): - print "-" * max(40, len(title) + 5) + print "-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5) print title - print "-" * max(40, len(title) + 5) + print "-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5) class DictCaseInsensitive(Dict): @@ -16,7 +17,7 @@ def print_banner(self, title): @staticmethod def make_banner(title): - return '\n'.join(["-" * max(40, len(title) + 5), title ,"-" * max(40, len(title) + 5)]) + return '\n'.join(["-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5), title ,"-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5)]) def __process_kwarg__(self, kwargs, key, default=None, replace_none_type=True): key = self.__key_transform__(key, kwargs.keys()) @@ -157,7 +158,7 @@ def print_banner(self, title): @staticmethod def make_banner(title): - return '\n'.join(["-" * max(40, len(title) + 5), title ,"-" * max(40, len(title) + 5)]) + return '\n'.join(["-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5), title ,"-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5)]) def __init__(self, *args, **kwargs): self.setCount(0) diff --git a/anknotes/error.py b/anknotes/error.py index 6445d45..f9856b8 100644 --- a/anknotes/error.py +++ b/anknotes/error.py @@ -51,4 +51,38 @@ def HandleEDAMRateLimitError(e, strError): showInfo(strError) elif EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.ToolTipError: show_tooltip(strError) - return True \ No newline at end of file + return True + +lastUnicodeError=None + +def HandleUnicodeError(log_header, e, guid, title, action='', attempt=1, content=None, field=None, attempt_max=3, attempt_min=1): + do_log = False + object = "" + e_type = e.__class__.__name__ + is_unicode = e_type.find("Unicode") > -1 + if is_unicode: + content_type = e.object.__class__.__name__ + object = e.object[e.start-20:e.start+20] + elif not content: + content = "Not Provided" + content_type = "N/A" + else: + content_type = content.__class__.__name__ + log_header += ': ' + e_type + ': {field}' + content_type + (' <%s>' % action if action else '') + save_header = log_header.replace('{field}', '') + ': ' + title + log_header = log_header.format(field='%s: ' % field if field else '') + + new_error = lastUnicodeError != save_header + + if is_unicode: + return_val = 1 if attempt < attempt_max else -1 + if new_error: + log(save_header + '\n' + '-' * ANKNOTES.FORMATTING.LINE_LENGTH, 'unicode', replace_newline=False); + lastUnicodeError = save_header + log(ANKNOTES.FORMATTING.TIMESTAMP_PAD + '\t - ' + (('Field %s' % field if field else 'Unknown Field') + ': ').ljust(20) + str_safe(object), 'unicode', timestamp=False) + else: + return_val = 0 + if attempt is 1 and content: log_dump(content, log_header, 'NonUnicodeErrors') + if (new_error and attempt >= attempt_min) or not is_unicode: + log_error(log_header + "\n - Error: %s\n - GUID: %s\n - Title: %s%s" % (str(e), guid, str_safe(title), '' if not object else "\n - Object: %s" % str_safe(object))) + return return_val \ No newline at end of file diff --git a/anknotes/logging.py b/anknotes/logging.py index c8e43fd..2f49e7b 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -47,7 +47,7 @@ def counts_as_str(count, max=None): if count == max: return "All %s" % str(count).center(3) return "Total %s of %s" % (str(count).center(3), str(max).center(3)) -def show_report(title, header=None, log_lines=None, delay=None, log_header_prefix = ' '*5, filename=None, blank_line_before=True, hr_if_empty=False): +def show_report(title, header=None, log_lines=None, delay=None, log_header_prefix = ' '*5, filename=None, blank_line_before=True, blank_line_after=True, hr_if_empty=False): if log_lines is None: log_lines = [] if header is None: header = [] lines = [] @@ -64,10 +64,10 @@ def show_report(title, header=None, log_lines=None, delay=None, log_header_prefi if blank_line_before: log_blank(filename=filename) log(title, filename=filename) if len(lines) == 1 and not lines[0]: - if hr_if_empty: log(" " + "-" * 185, timestamp=False, filename=filename) + if hr_if_empty: log(" " + "-" * ANKNOTES.FORMATTING.LINE_LENGTH, timestamp=False, filename=filename) return - log(" " + "-" * 185 + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True, filename=filename) - log_blank(filename=filename) + log(" " + "-" * ANKNOTES.FORMATTING.LINE_LENGTH + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True, filename=filename) + if blank_line_after: log_blank(filename=filename) def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0, cancelButton=False, richText=False, minHeight=None, minWidth=400, styleSheet=None, convertNewLines=True): @@ -140,7 +140,7 @@ def generate_diff(value_original, value): except: raise -def PadList(lst, length=25): +def PadList(lst, length=ANKNOTES.FORMATTING.LIST_PAD): newLst = [] for val in lst: if isinstance(val, list): newLst.append(PadList(val, length)) @@ -157,7 +157,7 @@ def JoinList(lst, joiners='\n', pad=0, depth=1): strr += JoinList(val, joiners, pad, depth+1) return strr -def PadLines(content, line_padding=32, line_padding_plus=0, line_padding_header='', pad_char=' ', **kwargs): +def PadLines(content, line_padding=ANKNOTES.FORMATTING.LINE_PADDING_HEADER, line_padding_plus=0, line_padding_header='', pad_char=' ', **kwargs): if not line_padding and not line_padding_plus and not line_padding_header: return content if not line_padding: line_padding = line_padding_plus; line_padding_plus=True if str(line_padding).isdigit(): line_padding = pad_char * int(line_padding) @@ -175,9 +175,11 @@ def item_to_list(item, list_from_unknown=True,chrs=''): return item def key_transform(keys, key): if keys is None: keys = self.keys() + key = key.strip() for k in keys: if k.lower() == key.lower(): return k return key + def get_kwarg(func_kwargs, key, **kwargs): kwargs['update_kwargs'] = False return process_kwarg(func_kwargs, key, **kwargs) @@ -227,7 +229,7 @@ def __get_args__(args, func_kwargs, *args_list, **kwargs_): if len(get_name_item) is 1 and isinstance(get_name_item[0], list): get_name_item = get_name_item[0] name = get_name_item[0] types=get_name_item[1] - print "Name: %s, Types: %s" % (name, str(types[0])) + # print "Name: %s, Types: %s" % (name, str(types[0])) name = name.replace('*', '') types = item_to_list(types) is_none_type = types[0] is None @@ -366,10 +368,12 @@ def rmtree_error(f, p, e): time.sleep(1) rm_log_path(filename, subfolders_only, retry_errors + 1) -def log_banner(title, filename, length=80, append_newline=True, *args, **kwargs): - log("-" * length, filename, clear=True, timestamp=False, *args, **kwargs) - log(title.center(length),filename, timestamp=False, *args, **kwargs) - log("-" * length, filename, timestamp=False, *args, **kwargs) +def log_banner(title, filename=None, length=ANKNOTES.FORMATTING.BANNER_MINIMUM, append_newline=True, timestamp=False, chr='-', center=True, clear=True, *args, **kwargs): + if length is 0: length = ANKNOTES.FORMATTING.LINE_LENGTH+1 + if center: title = title.center(length-TIMESTAMP_PAD_LENGTH if timestamp else 0) + log(chr * length, filename, clear=clear, timestamp=False, *args, **kwargs) + log(title, filename, timestamp=timestamp, *args, **kwargs) + log(chr * length, filename, timestamp=False, *args, **kwargs) if append_newline: log_blank(filename, *args, **kwargs) _log_filename_history = [] @@ -384,7 +388,7 @@ def end_current_log(fn=None): else: _log_filename_history = _log_filename_history[:-1] -def get_log_full_path(filename=None, extension='log', as_url_link=False, prefix=''): +def get_log_full_path(filename=None, extension='log', as_url_link=False, prefix='', **kwargs): global _log_filename_history log_base_name = FILES.LOGS.BASE_NAME filename_suffix = '' @@ -424,40 +428,58 @@ def encode_log_text(content, encode_text=True, **kwargs): if not encode_text or not isinstance(content, str) and not isinstance(content, unicode): return content try: return content.encode('utf-8') except Exception: return content -# @clockit -def log(content=None, filename=None, prefix='', clear=False, extension='log', - do_print=False, print_timestamp=False, replace_newline=None,timestamp=True, **kwargs): - kwargs = set_kwargs(kwargs, 'line_padding') - if content is None: content = '' - else: - content = obj2log_simple(content) - if len(content) == 0: content = '{EMPTY STRING}' - if content[0] == "!": content = content[1:]; prefix = '\n' - if filename and filename[0] is '+': - original_log = filename[1:].upper() - summary = " ** %s%s: " % ('' if original_log == 'ERROR' else 'CROSS-POST TO ', original_log) + content - log(summary[:200]) - full_path = get_log_full_path(filename, extension) + +def parse_log_content(content, prefix='', **kwargs): + if content is None: return '', prefix + content = obj2log_simple(content) + if len(content) == 0: content = '{EMPTY STRING}' + if content[0] == "!": content = content[1:]; prefix = '\n' + return content, prefix + +def process_log_content(content, prefix='', timestamp=None, do_encode=True, **kwargs): + content = pad_lines_regex(content, timestamp=timestamp, **kwargs) st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' + return prefix + ' ' + st + (encode_log_text(content, **kwargs) if do_encode else content), content + +def crosspost_log(content, filename=None, crosspost_to_default=False, crosspost=None, **kwargs): + if crosspost_to_default and filename: + summary = " ** %s%s: " % ('' if filename.upper() == 'ERROR' else 'CROSS-POST TO ', filename.upper()) + content + log(summary[:200], **kwargs) + if not crosspost: return + for fn in item_to_list(crosspost): log(content, fn, **kwargs) + +def pad_lines_regex(content, timestamp=None, replace_newline=None, try_decode=True, **kwargs): content = PadLines(content, **kwargs) - if timestamp or replace_newline: - try: content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content) - except UnicodeDecodeError: content = re.sub(r'[\r\n]+', u'\n'+'\t'*6, content.decode('utf-8')) - contents = prefix + ' ' + st + encode_log_text(content, **kwargs) + if not (timestamp and replace_newline is not False) and not replace_newline: return content + try: return re.sub(r'[\r\n]+', u'\n'+ANKNOTES.FORMATTING.TIMESTAMP_PAD, content) + except UnicodeDecodeError: + if not try_decode: raise + return re.sub(r'[\r\n]+', u'\n'+ANKNOTES.FORMATTING.TIMESTAMP_PAD, content.decode('utf-8')) + +def write_file_contents(content, full_path, clear=False, try_encode=True, do_print=False, print_timestamp=True, print_content=None, **kwargs): + if not os.path.exists(os.path.dirname(full_path)): full_path = get_log_full_path(full_path) with open(full_path, 'w+' if clear else 'a+') as fileLog: - try: print>> fileLog, contents - except UnicodeEncodeError: contents = contents.encode('utf-8'); print>> fileLog, contents - if do_print: print contents if print_timestamp else content + try: print>> fileLog, content + except UnicodeEncodeError: content = content.encode('utf-8'); print>> fileLog, content + if do_print: print content if print_timestamp or not print_content else print_content + +# @clockit +def log(content=None, filename=None, **kwargs): + kwargs = set_kwargs(kwargs, 'line_padding, line_padding_plus, line_padding_header', timestamp=True) + content, prefix = parse_log_content(content, **kwargs) + crosspost_log(content, filename, **kwargs) + full_path = get_log_full_path(filename, **kwargs) + content, print_content = process_log_content(content, prefix, **kwargs) + write_file_contents(content, full_path, print_content=print_content, **kwargs) def log_sql(content, **kwargs): log(content, 'sql', **kwargs) -def log_error(content, crosspost_to_default=True, **kwargs): - log(content, ('+' if crosspost_to_default else '') + 'error', **kwargs) - +def log_error(content, **kwargs): + log(content, 'error', **kwargs) def print_dump(obj): - content = pprint.pformat(obj, indent=4, width=80) + content = pprint.pformat(obj, indent=4, width=ANKNOTES.FORMATTING.PPRINT_WIDTH) content = content.replace(', ', ', \n ') content = content.replace('\r', '\r ').replace('\n', '\n ') @@ -465,8 +487,8 @@ def print_dump(obj): print content return content -def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, extension='log', crosspost_to_default=True): - content = pprint.pformat(obj, indent=4, width=80) +def log_dump(obj, title="Object", filename='', timestamp=True, extension='log', crosspost_to_default=True, **kwargs): + content = pprint.pformat(obj, indent=4, width=ANKNOTES.FORMATTING.PPRINT_WIDTH) try: content = content.decode('utf-8', 'ignore') except Exception: pass content = content.replace("\\n", '\n').replace('\\r', '\r') @@ -475,16 +497,11 @@ def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, exte log(summary[:200]) # filename = 'dump' + ('-%s' % filename if filename else '') full_path = get_log_full_path(filename, extension, prefix='dump') - st = '' - if timestamp: - st = datetime.now().strftime(ANKNOTES.DATE_FORMAT) - st = '[%s]: ' % st + st = '[%s]: ' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' - if title[0] == '-': - prefix = " **** Dumping %s" % title[1:] - else: - prefix = " **** Dumping %s" % title - if crosspost_to_default: log(prefix) + if title[0] == '-': crosspost_to_default = False; title = title[1:] + prefix = " **** Dumping %s" % title + if crosspost_to_default: log(prefix) content = encode_log_text(content) @@ -492,47 +509,30 @@ def log_dump(obj, title="Object", filename='', clear=False, timestamp=True, exte prefix += '\r\n' content = prefix + content.replace(', ', ', \n ') content = content.replace("': {", "': {\n ") - content = content.replace('\r', '\r ').replace('\n', - '\n ') + content = content.replace('\r', '\r' + ' ' * 30).replace('\n', '\n' + ' ' * 30) except: pass if not os.path.exists(os.path.dirname(full_path)): os.makedirs(os.path.dirname(full_path)) - with open(full_path, 'w+' if clear else 'a+') as fileLog: - try: - print>> fileLog, (u'\n %s%s' % (st, content)) - return - except: - pass - try: - print>> fileLog, (u'\n <1> %s%s' % (st, content.decode('utf-8'))) - return - except: - pass - try: - print>> fileLog, (u'\n <2> %s%s' % (st, content.encode('utf-8'))) - return - except: - pass - try: - print>> fileLog, ('\n <3> %s%s' % (st, content.decode('utf-8'))) - return - except: - pass - try: - print>> fileLog, ('\n <4> %s%s' % (st, content.encode('utf-8'))) - return - except: - pass - try: - print>> fileLog, (u'\n <5> %s%s' % (st, "Error printing content: " + str_safe(content))) - return - except: - pass - print>> fileLog, (u'\n <6> %s%s' % (st, "Error printing content: " + content[:10])) - + try_print(full_path, content, prefix, **kwargs) +def try_print(full_path, content, prefix='', line_prefix=u'\n ', attempt=0, clear=False): + try: + print_content = line_prefix + (u' <%d>' % attempt if attempt > 0 else u'') + u' ' + st + if attempt is 0: print_content += content + elif attempt is 1: print_content += content.decode('utf-8') + elif attempt is 2: print_content += content.encode('utf-8') + elif attempt is 3: print_content = print_content.encode('utf-8') + content.encode('utf-8') + elif attempt is 4: print_content = print_content.decode('utf-8') + content.decode('utf-8') + elif attempt is 5: print_content += "Error printing content: " + str_safe(content) + elif attempt is 6: print_content += "Error printing content: " + content[:10] + elif attempt is 7: print_content += "Unable to print content." + with open(full_path, 'w+' if clear else 'a+') as fileLog: + print>> fileLog, print_content + except: + if attempt < 8: try_print(full_path, content, prefix=prefix, line_prefix=line_prefix, attempt=attempt+1, clear=clear) + def log_api(method, content='', **kwargs): if content: content = ': ' + content log(" API_CALL [%3d]: %10s%s" % (get_api_call_count(), method, content), 'api', **kwargs) diff --git a/anknotes/menu.py b/anknotes/menu.py index 6c43ba7..c3dbfdc 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -228,7 +228,6 @@ def validate_pending_notes(showAlerts=True, uploadAfterValidation=True, callback info = ("ERROR: {%s}<HR>" % stderrdata) if stderrdata else '' allowUpload = True if showAlerts: - log('vpn stdout: ' + FILES.SCRIPTS.VALIDATION + '\n' + stdoutdata) tds = [[str(count), '<a href="%s">VIEW %s VALIDATIONS LOG</a>' % (fn, key.upper())] for key, fn, count in [ [key, get_log_full_path('MakeNoteQueue\\' + key, as_url_link=True), int(re.search(r'CHECKING +(\d{1,3}) +' + key.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1))] for key in ['Pending', 'Successful', 'Failed']] if count > 0] @@ -282,7 +281,7 @@ def see_also(steps=None, showAlerts=None, validationComplete=False): controller.create_auto_toc() if 4 in steps: if validationComplete: - log(" > See Also: Step 4A: Validate and Upload Auto TOC Notes: Upload Validated Notes") + log(" > See Also: Step 4B: Validate and Upload Auto TOC Notes: Upload Validated Notes") upload_validated_notes(multipleSteps) validationComplete = False else: steps = [-4] @@ -298,7 +297,7 @@ def see_also(steps=None, showAlerts=None, validationComplete=False): detect_see_also_changes.main() if 8 in steps: if validationComplete: - log(" > See Also: Step 8A: Validate and Upload Modified Evernote Notes: Upload Validated Notes") + log(" > See Also: Step 8B: Validate and Upload Modified Evernote Notes: Upload Validated Notes") upload_validated_notes(multipleSteps) else: steps = [-8] if 9 in steps: @@ -307,7 +306,7 @@ def see_also(steps=None, showAlerts=None, validationComplete=False): do_validation = steps[0]*-1 if do_validation>0: - log(" > See Also: Step %dB: Validate and Upload %s Notes: Validate Notes" % (do_validation, {4: 'Auto TOC', 8: 'Modified Evernote'}[do_validation])) + log(" > See Also: Step %dA: Validate and Upload %s Notes: Validate Notes" % (do_validation, {4: 'Auto TOC', 8: 'Modified Evernote'}[do_validation])) remaining_steps = remaining_steps[remaining_steps.index(do_validation):] validate_pending_notes(showAlerts, callback=lambda: see_also(remaining_steps, False, True)) diff --git a/anknotes/shared.py b/anknotes/shared.py index 5ebb939..47d3c47 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -33,7 +33,7 @@ def get_friendly_interval_string(lastImport): lastImportStr = "%d days" % td.days else: hours = round(hours) - hours_str = '' if hours == 0 else ('1:%2d hr' % minutes) if hours == 1 else '%d Hours' % hours + hours_str = '' if hours == 0 else ('1:%02d hr' % minutes) if hours == 1 else '%d Hours' % hours if days == 1: lastImportStr = "One Day%s" % ('' if hours == 0 else ', ' + hours_str) elif hours > 0: diff --git a/anknotes/structs.py b/anknotes/structs.py index b862832..fb8ea3a 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -558,7 +558,7 @@ def SummaryList(self): ] @property - def Summary(self): return JoinList(self.SummaryList, ' | ', 31) + def Summary(self): return JoinList(self.SummaryList, ' | ', ANKNOTES.FORMATTING.PROGRESS_SUMMARY_PAD) def loadAlreadyUpdated(self, db_guids): self.GUIDs.Server.Existing.UpToDate = db_guids @@ -659,7 +659,7 @@ def SummaryList(self): "Update Count: %d" % self.UpdateCount]] @property - def Summary(self): return JoinList(self.SummaryList, ['\n', ' | '], 31) + def Summary(self): return JoinList(self.SummaryList, ['\n', ' | '], ANKNOTES.FORMATTING.PROGRESS_SUMMARY_PAD) @property def QueryLimit(self): return EVERNOTE.IMPORT.QUERY_LIMIT From 73a4b4e40b3ffe03ea68bca3228cf8da6ce5abcc Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 30 Sep 2015 01:30:28 -0400 Subject: [PATCH 38/70] Safer deletion of log folders --- anknotes/logging.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/anknotes/logging.py b/anknotes/logging.py index 2f49e7b..a3159ae 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -351,7 +351,7 @@ def log_plain(*args, **kwargs): def rm_log_path(filename='*', subfolders_only=False, retry_errors=0): path = os.path.dirname(os.path.abspath(get_log_full_path(filename))) - if path is FOLDERS.LOGS or path in FOLDERS.LOGS: return + if path is FOLDERS.LOGS or FOLDERS.LOGS not in path: return rm_log_path.errors = [] def del_subfolder(arg=None,dirname=None,filenames=None, is_subfolder=True): def rmtree_error(f, p, e): @@ -476,6 +476,7 @@ def log_sql(content, **kwargs): log(content, 'sql', **kwargs) def log_error(content, **kwargs): + kwargs = set_kwargs(kwargs, ['crosspost_to_default', True]) log(content, 'error', **kwargs) def print_dump(obj): From c552a7e24c2dfe38aaf5bb9bed20511eca63506c Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 30 Sep 2015 01:37:34 -0400 Subject: [PATCH 39/70] Fix for user constants file --- anknotes/constants_user.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/anknotes/constants_user.py b/anknotes/constants_user.py index 246cb1d..288c37f 100644 --- a/anknotes/constants_user.py +++ b/anknotes/constants_user.py @@ -1,9 +1,18 @@ +#!/usr/bin/env python # INSTRUCTIONS: # USE THIS FILE TO OVERRIDE THE MAIN SETTINGS FILE # PREFIX ALL SETTINGS WITH THE constants MODULE REFERENCE AS SHOWN BELOW: # DON'T FORGET TO REGENERATE ANY VARIABLES THAT DERIVE FROM THE ONES YOU ARE CHANGING -from anknotes import constants -# constants.EVERNOTE.API.IS_SANDBOXED = True -# constants.SETTINGS.EVERNOTE.AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + constants.EVERNOTE.API.CONSUMER_KEY + ( - # "_SANDBOX" if constants.EVERNOTE.API.IS_SANDBOXED else "") +try: from anknotes import constants +except: + import os + import imp + path = os.path.dirname(__file__) + name = 'constants' + modfile, modpath, description = imp.find_module(name, [path + '\\']) + constants=imp.load_module(name, modfile, modpath, description) + modfile.close() + +# constants.EVERNOTE.API.IS_SANDBOXED = True +# constants.SETTINGS.EVERNOTE.AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + constants.EVERNOTE.API.CONSUMER_KEY + ("_SANDBOX" if constants.EVERNOTE.API.IS_SANDBOXED else "") \ No newline at end of file From 1506455ff0777e21d9086ffba446df1ef141c76f Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 30 Sep 2015 03:34:47 -0400 Subject: [PATCH 40/70] Bug fix for find deleted notes --- anknotes/db.py | 4 ++++ anknotes/menu.py | 19 +++++++------------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/anknotes/db.py b/anknotes/db.py index f3618ec..104d6fb 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -48,6 +48,10 @@ def ankDB(reset=False): def escape_text_sql(title): return title.replace("'", "''") +def delete_anki_notes_and_cards_by_guid(evernote_guids): + ankDB().executemany("DELETE FROM cards WHERE nid in (SELECT id FROM notes WHERE flds LIKE '%' || ? || '%'); " + + "DELETE FROM notes WHERE flds LIKE '%' || ? || '%'", + [[FIELDS.EVERNOTE_GUID_PREFIX + x, FIELDS.EVERNOTE_GUID_PREFIX + x] for x in evernote_guids]) def get_evernote_title_from_guid(guid): return ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, guid)) diff --git a/anknotes/menu.py b/anknotes/menu.py index c3dbfdc..acc8b8e 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -153,11 +153,12 @@ def find_deleted_notes(automated=False): if not automated: showInfo("""In order for this to work, you must create a 'Table of Contents' Note using the Evernote desktop application. Include all notes that you want to sync with Anki. -Export this note to the following path: '%s'. +Export this note to the following path: +<b>%s</b> Press Okay to save and close your Anki collection, open the command-line deleted notes detection tool, and then re-open your Anki collection. -Once the command line tool is done running, you will get a summary of the results, and will be prompted to delete Anki Orphan Notes or download Missing Evernote Notes""" % FILES.USER.TABLE_OF_CONTENTS_ENEX, +Once the command line tool is done running, you will get a summary of the results, and will be prompted to delete Anki Orphan Notes or download Missing Evernote Notes""".replace('\n', '\n<br />') % FILES.USER.TABLE_OF_CONTENTS_ENEX, richText=True) # mw.col.save() @@ -192,25 +193,19 @@ def find_deleted_notes(automated=False): 0] if code == 'ANKNOTES_DEL_%d' % anknotes_dels_count: ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, [[x] for x in anknotes_dels]) - ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", - [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anknotes_dels]) + delete_anki_notes_and_cards_by_guid(anknotes_dels) db_changed = True show_tooltip("Deleted all %d Orphan Anknotes DB Notes" % anknotes_dels_count, 5000, 3000) if anki_dels_count > 0: code = getText("Please enter code 'ANKI_DEL_%d' to delete your orphan Anki note(s)" % anki_dels_count)[0] if code == 'ANKI_DEL_%d' % anki_dels_count: - ankDB().executemany("DELETE FROM cards as c, notes as n WHERE c.nid = n.id AND n.flds LIKE '%' | ? | '%'", - [[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in anki_dels]) + delete_anki_notes_and_cards_by_guid(anki_dels) db_changed = True - show_tooltip("Deleted all %d Orphan Anki Notes" % anki_dels_count, 5000, 3000) + show_tooltip("Deleted all %d Orphan Anki Notes" % anki_dels_count, 5000, 6000) if db_changed: ankDB().commit() if missing_evernote_notes_count > 0: - evernote_confirm = "Would you like to import %d missing Evernote Notes?<BR><BR><a href='%s'>Click to view results</a>" % ( - missing_evernote_notes_count, - convert_filename_to_local_link(get_log_full_path(FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES))) - ret = showInfo(evernote_confirm, cancelButton=True, richText=True) - if ret: + if showInfo("Would you like to import %d missing Evernote Notes?<BR><BR><a href='%s'>Click to view results</a>" % (missing_evernote_notes_count, convert_filename_to_local_link(get_log_full_path(FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES))), cancelButton=True, richText=True): import_from_evernote_manual_metadata(missing_evernote_notes) From 8b6939a71f22d3a191dec831b8d3e12a04d3c1d5 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 30 Sep 2015 04:19:05 -0400 Subject: [PATCH 41/70] Fix argument processing --- anknotes/Anki.py | 2 +- anknotes/ankEvernote.py | 2 +- anknotes/logging.py | 17 +++++++++-------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/anknotes/Anki.py b/anknotes/Anki.py index 67ffdd5..f08e917 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -404,7 +404,7 @@ def insert_toc_into_see_also(self): if not ankiNote: log.dump(toc_guids, 'Missing Anki Note for ' + source_guid, 'insert_toc', timestamp=False, crosspost_to_default=False) if not logged_missing_anki_note: - log_error('ERROR DURING %s: Missing Anki Note(s) for TOC entry. See insert_toc log for more details' % action_title) + log_error('%s: Missing Anki Note(s) for TOC entry. See insert_toc log for more details' % action_title) logged_missing_anki_note = True else: fields = get_dict_from_list(ankiNote.items()) diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index a14f083..4437d22 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -121,7 +121,7 @@ def validateNoteBody(self, noteBody, title="Note Body"): log("Loading ENML DTD", "lxml", timestamp=False, do_print=True) self.DTD = etree.DTD(FILES.ANCILLARY.ENML_DTD) log("DTD Loaded in %s\n" % str(timerInterval), "lxml", timestamp=False, do_print=True) - log(' '*7+' > Note Validation: ENML DTD Loaded in %s' % str(timerInterval)) + log(' > Note Validation: ENML DTD Loaded in %s' % str(timerInterval)) timerInterval.stop() del timerInterval diff --git a/anknotes/logging.py b/anknotes/logging.py index a3159ae..0250996 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -95,7 +95,7 @@ def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0 messageBox.setTextFormat(textFormat) # message = ' %s %s' % (styleSheet, message) - log_plain(message, 'showInfo', clear=True) + # log_plain(message, 'showInfo', clear=True) messageBox.setWindowIcon(icoEvernoteWeb) messageBox.setWindowIconText("Anknotes") messageBox.setText(message) @@ -264,7 +264,7 @@ def __get_args__(args, func_kwargs, *args_list, **kwargs_): return results def __get_default_listdict_args__(args, kwargs, name): results_expanded = __get_args__(args, kwargs, [name + '*', [list, str, unicode], name , [dict, DictCaseInsensitive]]) - results_expanded[2] = item_to_list(results_expanded[2], chrs=',') + # results_expanded[2] = item_to_list(results_expanded[2], chrs=',') if results_expanded[2] is None: results_expanded[2] = [] if results_expanded[3] is None: results_expanded[3] = {} return results_expanded @@ -278,13 +278,14 @@ def get_kwargs(func_kwargs, *args_list, **kwargs): return process_kwargs(func_kwargs, get_args=lst, **kwargs) def set_kwargs(func_kwargs, *args, **kwargs): + new_args=[] kwargs, name, update_kwargs = get_kwargs(kwargs, ['name', None, 'update_kwargs', None]) - args, kwargs, list, dict = __get_default_listdict_args__(args, kwargs, 'set') - new_args=[]; - for arg in args: new_args += item_to_list(arg, False) - dict.update({key: None for key in list + new_args }) - dict.update(kwargs) - return DictCaseInsensitive(process_kwargs(func_kwargs, set_dict=dict, name=name, update_kwargs=update_kwargs)) + args, kwargs, lst, dct = __get_default_listdict_args__(args, kwargs, 'set') + if isinstance(lst, list): dct.update({lst[i*2]: lst[i*2+1] for i in range(0, len(lst)/2)}); lst = [] + for arg in args: new_args += item_to_lst(arg, False) + dct.update({key: None for key in item_to_list(lst, chrs=',') + new_args }) + dct.update(kwargs) + return DictCaseInsensitive(process_kwargs(func_kwargs, set_dict=dct, name=name, update_kwargs=update_kwargs)) def obj2log_simple(content): if not isinstance(content, str) and not isinstance(content, unicode): From 5499c6c4a7aea5b6f0d84e55fdbfe2d4d15c4c95 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Wed, 30 Sep 2015 05:26:34 -0400 Subject: [PATCH 42/70] Minor changes --- anknotes/EvernoteNoteFetcher.py | 1 + anknotes/ankEvernote.py | 130 ++++++++++++-------------------- 2 files changed, 51 insertions(+), 80 deletions(-) diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index cc0463c..ba2acf9 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -170,6 +170,7 @@ def getNote(self, evernote_guid=None): if evernote_guid: self.result.Note = None self.evernote_guid = evernote_guid + self.evernote.evernote_guid = evernote_guid self.__update_sequence_number__ = self.evernote.metadata[ self.evernote_guid].updateSequenceNum if not self.use_local_db_only else -1 if self.getNoteLocal(): return True diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index 4437d22..fed9abf 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -103,8 +103,7 @@ def initialize_note_store(self): if not self.client: log_error("Client does not exist for some reason. Did we not initialize Evernote Class? Current token: " + str(self.token)) self.setup_client() - try: - self.noteStore = self.client.get_note_store() + try: self.noteStore = self.client.get_note_store() except EDAMSystemException as e: if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return EvernoteAPIStatus.RateLimitError @@ -113,36 +112,32 @@ def initialize_note_store(self): return EvernoteAPIStatus.SocketError return EvernoteAPIStatus.Success + def loadDTD(self): + if self.DTD: return + timerInterval = stopwatch.Timer() + log("Loading ENML DTD", "lxml", timestamp=False, do_print=True) + self.DTD = etree.DTD(FILES.ANCILLARY.ENML_DTD) + log("DTD Loaded in %s\n" % str(timerInterval), "lxml", timestamp=False, do_print=True) + log(' > Note Validation: ENML DTD Loaded in %s' % str(timerInterval)) + del timerInterval + def validateNoteBody(self, noteBody, title="Note Body"): - # timerFull = stopwatch.Timer() - # timerInterval = stopwatch.Timer(False) - if not self.DTD: - timerInterval = stopwatch.Timer() - log("Loading ENML DTD", "lxml", timestamp=False, do_print=True) - self.DTD = etree.DTD(FILES.ANCILLARY.ENML_DTD) - log("DTD Loaded in %s\n" % str(timerInterval), "lxml", timestamp=False, do_print=True) - log(' > Note Validation: ENML DTD Loaded in %s' % str(timerInterval)) - timerInterval.stop() - del timerInterval - + self.loadDTD() noteBody = noteBody.replace('"http://xml.evernote.com/pub/enml2.dtd"', '"%s"' % convert_filename_to_local_link(FILES.ANCILLARY.ENML_DTD) ) parser = etree.XMLParser(dtd_validation=True, attribute_defaults=True) - try: - root = etree.fromstring(noteBody, parser) + try: root = etree.fromstring(noteBody, parser) except Exception as e: log_str = "XML Loading of %s failed.\n - Error Details: %s" % (title, str(e)) log(log_str, "lxml", timestamp=False, do_print=True) log_error(log_str, False) return False, [log_str] - try: - success = self.DTD.validate(root) + try: success = self.DTD.validate(root) except Exception as e: log_str = "DTD Validation of %s failed.\n - Error Details: %s" % (title, str(e)) log(log_str, "lxml", timestamp=False, do_print=True) log_error(log_str, False) return False, [log_str] - log("Validation %-9s for %s" % ("Succeeded" if success else "Failed", title), "lxml", timestamp=False, - do_print=True) + log("Validation %-9s for %s" % ("Succeeded" if success else "Failed", title), "lxml", timestamp=False, do_print=True) errors = [str(x) for x in self.DTD.error_log.filter_from_errors()] if not success: log_str = "DTD Validation Errors for %s: \n%s\n" % (title, str(errors)) @@ -177,16 +172,7 @@ def makeNoteBody(content, resources=None, encode=True): if not nBody.startswith("<?xml"): nBody = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" nBody += "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" - nBody += "<en-note>%s" % content - # if resources: - # ### Add Resource objects to note body - # nBody += "<br />" * 2 - # ourNote.resources = resources - # for resource in resources: - # hexhash = binascii.hexlify(resource.data.bodyHash) - # nBody += "Attachment with hash %s: <br /><en-media type=\"%s\" hash=\"%s\" /><br />" % \ - # (hexhash, resource.mime, hexhash) - nBody += "</en-note>" + nBody += "<en-note>%s" % content + "</en-note>" if encode and isinstance(nBody, unicode): nBody = nBody.encode('utf-8') return nBody @@ -222,37 +208,25 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot :rtype : (EvernoteAPIStatus, EvernoteNote) :returns Status and Note """ - if enNote: - noteTitle = enNote.FullTitle - noteContents = enNote.Content - tagNames = enNote.Tags - if enNote.NotebookGuid: parentNotebook = enNote.NotebookGuid - guid = enNote.Guid - + if enNote: guid, noteTitle, noteContents, tagNames, parentNotebook = enNote.Guid, enNote.FullTitle, enNote.Content, enNote.Tags, enNote.NotebookGuid or parentNotebook if resources is None: resources = [] callType = "create" validation_status = EvernoteAPIStatus.Uninitialized if validated is None: - if not EVERNOTE.UPLOAD.VALIDATION.ENABLED: - validated = True + if not EVERNOTE.UPLOAD.VALIDATION.ENABLED: validated = True else: - validation_status = self.addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames, parentNotebook, - resources, guid) - if not validation_status.IsSuccess and not self.hasValidator: - return validation_status, None + validation_status = self.addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames, parentNotebook, resources, guid) + if not validation_status.IsSuccess and not self.hasValidator: return validation_status, None ourNote = EvernoteNote() ourNote.title = noteTitle.encode('utf-8') - if guid: - callType = "update" - ourNote.guid = guid + if guid: callType = "update"; ourNote.guid = guid - ## Build body of note + ## Build body of note nBody = self.makeNoteBody(noteContents, resources) - if not validated is True and not validation_status.IsSuccess: + if validated is not True and not validation_status.IsSuccess: success, errors = self.validateNoteBody(nBody, ourNote.title) - if not success: - return EvernoteAPIStatus.UserError, None + if not success: return EvernoteAPIStatus.UserError, None ourNote.content = nBody notestore_status = self.initialize_note_store() @@ -260,16 +234,14 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot while '' in tagNames: tagNames.remove('') if len(tagNames) > 0: - if EVERNOTE.API.IS_SANDBOXED and not '#Sandbox' in tagNames: - tagNames.append("#Sandbox") + if EVERNOTE.API.IS_SANDBOXED and not '#Sandbox' in tagNames: tagNames.append("#Sandbox") ourNote.tagNames = tagNames ## parentNotebook is optional; if omitted, default notebook is used if parentNotebook: - if hasattr(parentNotebook, 'guid'): - ourNote.notebookGuid = parentNotebook.guid - elif isinstance(parentNotebook, str) or isinstance(parentNotebook, unicode): - ourNote.notebookGuid = parentNotebook + if hasattr(parentNotebook, 'guid'): ourNote.notebookGuid = parentNotebook.guid + elif hasattr(parentNotebook, 'Guid'): ourNote.notebookGuid = parentNotebook.Guid + elif isinstance(parentNotebook, str) or isinstance(parentNotebook, unicode): ourNote.notebookGuid = parentNotebook ## Attempt to create note in Evernote account @@ -288,31 +260,31 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot ## See EDAMErrorCode enumeration for error code explanation ## http://dev.evernote.com/documentation/reference/Errors.html#Enum_EDAMErrorCode print "EDAMUserException:", edue - log_error("-------------------------------------------------") - log_error("EDAMUserException: " + str(edue)) - log_error(str(ourNote.tagNames)) - log_error(str(ourNote.content)) - log_error("-------------------------------------------------\r\n") + log_error("-" * 50, crosspost_to_default=False) + log_error("EDAMUserException: " + str(edue), crosspost='api') + log_error(str(ourNote.tagNames), crosspost_to_default=False) + log_error(str(ourNote.content), crosspost_to_default=False) + log_error("-" * 50 + "\r\n", crosspost_to_default=False) if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return EvernoteAPIStatus.UserError, None except EDAMNotFoundException, ednfe: print "EDAMNotFoundException:", ednfe - log_error("-------------------------------------------------") - log_error("EDAMNotFoundException: " + str(ednfe)) + log_error("-" * 50, crosspost_to_default=False) + log_error("EDAMNotFoundException: " + str(ednfe), crosspost='api') if callType is "update": - log_error(str(ourNote.guid)) + log_error('GUID: ' + str(ourNote.guid), crosspost_to_default=False) if ourNote.notebookGuid: - log_error(str(ourNote.notebookGuid)) - log_error("-------------------------------------------------\r\n") + log_error('Notebook GUID: ' + str(ourNote.notebookGuid), crosspost_to_default=False) + log_error("-" * 50 + "\r\n", crosspost_to_default=False) if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise return EvernoteAPIStatus.NotFoundError, None except Exception, e: print "Unknown Exception:", e - log_error("-------------------------------------------------") + log_error("-" * 50, crosspost_to_default=False) log_error("Unknown Exception: " + str(e)) - log_error(str(ourNote.tagNames)) - log_error(str(ourNote.content)) - log_error("-------------------------------------------------\r\n") + log_error(str(ourNote.tagNames), crosspost_to_default=False) + log_error(str(ourNote.content), crosspost_to_default=False) + log_error("-" * 50 + "\r\n", crosspost_to_default=False) # return EvernoteAPIStatus.UnhandledError, None raise # noinspection PyUnboundLocalVariable @@ -330,22 +302,17 @@ def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): :param use_local_db_only: Do not initiate API calls :return: EvernoteNoteFetcherResults """ - if not hasattr(self, 'guids') or evernote_guids: self.evernote_guids = evernote_guids - if not use_local_db_only: - self.check_ancillary_data_up_to_date() + if not hasattr(self, 'evernote_guids') or evernote_guids: self.evernote_guids = evernote_guids + if not use_local_db_only: self.check_ancillary_data_up_to_date() fetcher = EvernoteNoteFetcher(self, use_local_db_only=use_local_db_only) - if len(evernote_guids) == 0: - fetcher.results.Status = EvernoteAPIStatus.EmptyRequest - return fetcher.results + if len(evernote_guids) == 0: fetcher.results.Status = EvernoteAPIStatus.EmptyRequest; return fetcher.results if inAnki: fetcher.evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() fetcher.keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE) fetcher.deleteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False) fetcher.tagsToDelete = mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "").replace(',', ' ').split() for evernote_guid in self.evernote_guids: - self.evernote_guid = evernote_guid - if not fetcher.getNote(evernote_guid): - return fetcher.results + if not fetcher.getNote(evernote_guid): return fetcher.results return fetcher.results def check_ancillary_data_up_to_date(self): @@ -495,8 +462,10 @@ def get_matching_tag_data(self, tag_guids=None, tag_names=None): self.update_tags_database("Missing Tag %s(s) Were found when attempting to get matching tag data" % ('Guids' if from_guids else 'Names')) missing_tags = self.get_missing_tags(tags_original, from_guids) if missing_tags: - log_error("FATAL ERROR: Tag %s(s) %s were not found on the Evernote Servers" % ('Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)))) - raise EDAMNotFoundException() + identifier = 'Guid' if from_guids else 'Name' + keys = ', '.join(sorted(missing_tags)) + log_error("FATAL ERROR: Tag %s(s) %s were not found on the Evernote Servers" % (identifier, keys)) + raise EDAMNotFoundException(identifier.lower(), keys) if from_guids: tags_dict = {x: self.tag_data[x] for x in tags_original} else: tags_dict = {[k for k, v in self.tag_data.items() if v.Name is tag_name][0]: tag_name for tag_name in tags_original} tagNamesToImport = get_tag_names_to_import(tags_dict) @@ -509,6 +478,7 @@ def get_matching_tag_data(self, tag_guids=None, tag_names=None): tagNames.append(v.Name if is_struct else v) tagNames = sorted(tagNames, key=lambda s: s.lower()) return tagGuids, tagNames + def check_tags_up_to_date(self): for evernote_guid in self.evernote_guids: if evernote_guid not in self.metadata: From 3366e38ee224ac215b94617277ab3a27ca25ebd4 Mon Sep 17 00:00:00 2001 From: Fritz Otlinghaus <fritz@otlinghaus.it> Date: Thu, 1 Oct 2015 11:52:39 +0200 Subject: [PATCH 43/70] replaced tabs with 4 spaces and fixed some case where a emtpy evernote guid set would cause crashes --- anknotes/Anki.py | 1178 ++++++++++++------------ anknotes/AnkiNotePrototype.py | 1094 +++++++++++----------- anknotes/Controller.py | 295 +++--- anknotes/EvernoteImporter.py | 533 +++++------ anknotes/EvernoteNoteFetcher.py | 333 +++---- anknotes/EvernoteNotePrototype.py | 259 +++--- anknotes/EvernoteNoteTitle.py | 439 ++++----- anknotes/EvernoteNotes.py | 813 ++++++++-------- anknotes/__main__.py | 384 ++++---- anknotes/_re.py | 468 +++++----- anknotes/ankEvernote.py | 971 ++++++++++---------- anknotes/constants.py | 388 ++++---- anknotes/constants_user.py | 32 +- anknotes/counters.py | 908 +++++++++--------- anknotes/db.py | 350 +++---- anknotes/detect_see_also_changes.py | 509 +++++----- anknotes/enums.py | 157 ++-- anknotes/error.py | 152 +-- anknotes/find_deleted_notes.py | 261 +++--- anknotes/graphics.py | 16 +- anknotes/html.py | 418 +++++---- anknotes/logging.py | 1184 +++++++++++++----------- anknotes/menu.py | 506 +++++----- anknotes/settings.py | 1258 ++++++++++++------------- anknotes/shared.py | 167 ++-- anknotes/structs.py | 1326 ++++++++++++++------------- anknotes/toc.py | 648 ++++++------- anknotes/version.py | 34 +- 28 files changed, 7834 insertions(+), 7247 deletions(-) diff --git a/anknotes/Anki.py b/anknotes/Anki.py index f08e917..293f020 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -4,9 +4,9 @@ import sys try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite ### Anknotes Imports from anknotes.AnkiNotePrototype import AnkiNotePrototype @@ -20,582 +20,602 @@ ### Anki Imports try: - import anki - from anki.notes import Note as AnkiNote - import aqt - from aqt import mw -except: pass + import anki + from anki.notes import Note as AnkiNote + import aqt + from aqt import mw +except: + pass + class Anki: - def __init__(self): - self.deck = None - self.templates = None - - @staticmethod - def get_notebook_guid_from_ankdb(evernote_guid): - return ankDB().scalar("SELECT notebookGuid FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, evernote_guid)) - - def get_deck_name_from_evernote_notebook(self, notebookGuid, deck=None): - if not deck: - deck = self.deck if self.deck else "" - if not hasattr(self, 'notebook_data'): - self.notebook_data = {} - if not notebookGuid in self.notebook_data: - # log_error("Unexpected error: Notebook GUID '%s' could not be found in notebook data: %s" % (notebookGuid, str(self.notebook_data))) - notebook = EvernoteNotebook(fetch_guid=notebookGuid) - if not notebook.success: - log_error(" get_deck_name_from_evernote_notebook FATAL ERROR: UNABLE TO FIND NOTEBOOK '%s'. " % notebookGuid) - return None - # log("Getting notebook info: %s" % str(notebook)) - self.notebook_data[notebookGuid] = notebook - notebook = self.notebook_data[notebookGuid] - if notebook.Stack: - deck += u'::' + notebook.Stack - deck += "::" + notebook.Name - deck = deck.replace(": ", "::") - if deck[:2] == '::': - deck = deck[2:] - return deck - - def update_evernote_notes(self, evernote_notes, log_update_if_unchanged=True): - """ - Update Notes in Anki Database - :type evernote_notes: list[EvernoteNotePrototype.EvernoteNotePrototype] - :rtype : int - :param evernote_notes: List of EvernoteNote returned from server or local db - :param log_update_if_unchanged: - :return: Count of notes successfully updated - """ - return self.add_evernote_notes(evernote_notes, True, log_update_if_unchanged=log_update_if_unchanged) - - def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchanged=True): - """ - Add Notes to or Update Notes in Anki Database - :param evernote_notes: - :param update: - :param log_update_if_unchanged: - :type evernote_notes: list[EvernoteNotePrototype.EvernoteNotePrototype] - :type update: bool - :return: Count of notes successfully added or updated - """ - count_update = 0 - action_str = ['ADDING', 'UPDATING'][update] - tmr = stopwatch.Timer(len(evernote_notes), 100, infoStr=action_str + " EVERNOTE NOTE(S) %s ANKI" % ['TO', 'IN'][update], label='AddEvernoteNotes') - - # if tmr.willReportProgress: - # log_banner(), tmr.label, append_newline=False) - for ankiNote in evernote_notes: - try: - title = ankiNote.FullTitle - content = ankiNote.Content - if isinstance(content, str): - content = unicode(content, 'utf-8') - anki_field_info = { - FIELDS.TITLE: title, - FIELDS.CONTENT: content, - FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + ankiNote.Guid, - FIELDS.UPDATE_SEQUENCE_NUM: str(ankiNote.UpdateSequenceNum), - FIELDS.SEE_ALSO: u'' - } - except: - log_error("Unable to set field info for: Note '%s': '%s'" % (ankiNote.Title, ankiNote.Guid)) - log_dump(ankiNote.Content, " NOTE CONTENTS ") - # log_dump(ankiNote.Content.encode('utf-8'), " NOTE CONTENTS ") - raise - if tmr.step(): - log(['Adding', 'Updating'][update] + " Note %5s: %s: %s" % ('#' + str(tmr.count), tmr.progress, title), tmr.label) - baseNote = None - if update: - baseNote = self.get_anki_note_from_evernote_guid(ankiNote.Guid) - if not baseNote: - log_error('Updating note %s: COULD NOT FIND BASE NOTE FOR ANKI NOTE ID' % ankiNote.Guid) - tmr.reportStatus(EvernoteAPIStatus.MissingDataError) - continue - if ankiNote.Tags is None: - log_error("Could note find tags object for note %s: %s. " % (ankiNote.Guid, ankiNote.FullTitle)) - tmr.reportStatus(EvernoteAPIStatus.MissingDataError) - continue - anki_note_prototype = AnkiNotePrototype(self, anki_field_info, ankiNote.TagNames, baseNote, - notebookGuid=ankiNote.NotebookGuid, count=tmr.count, - count_update=tmr.counts.success, max_count=tmr.counts.max.val) - anki_note_prototype._log_update_if_unchanged_ = log_update_if_unchanged - anki_result = anki_note_prototype.update_note() if update else anki_note_prototype.add_note() - if anki_result != -1: tmr.reportSuccess(update, True) - else: - tmr.reportError(True) - log("ANKI ERROR WHILE %s EVERNOTE NOTES: " % action_str + str(anki_result), 'AddEvernoteNotes-Error') - tmr.Report() - return tmr.counts.success - - def delete_anki_cards(self, evernote_guids): - col = self.collection() - card_ids = [] - for evernote_guid in evernote_guids: - card_ids += mw.col.findCards(FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid) - col.remCards(card_ids) - return len(card_ids) - - @staticmethod - def get_evernote_model_styles(): - if MODELS.OPTIONS.IMPORT_STYLES: return '@import url("%s");' % FILES.ANCILLARY.CSS - return file(os.path.join(FOLDERS.ANCILLARY, FILES.ANCILLARY.CSS), 'r').read() - - def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): - model = mm.byName(modelName) - model_css = self.get_evernote_model_styles() - templates = self.get_templates(modelName==MODELS.DEFAULT) - if model and modelName is MODELS.DEFAULT: - front = model['tmpls'][0]['qfmt'] - evernote_account_info = get_evernote_account_ids() - if not evernote_account_info.Valid: - info = ankDB().first("SELECT uid, shard, COUNT(uid) as c1, COUNT(shard) as c2 from %s GROUP BY uid, shard ORDER BY c1 DESC, c2 DESC LIMIT 1" % TABLES.SEE_ALSO) - if info and evernote_account_info.update(info[0], info[1]): forceRebuild = True - if evernote_account_info.Valid: - if not "evernote_uid = '%s'" % evernote_account_info.uid in front or not "evernote_shard = '%s'" % evernote_account_info.shard in front: forceRebuild = True - if model['css'] != model_css: forceRebuild = True - if model['tmpls'][0]['qfmt'] != templates['Front']: forceRebuild = True - if not model or forceRebuild: - if model: - for t in model['tmpls']: - t['qfmt'] = templates['Front'] - t['afmt'] = templates['Back'] - model['css'] = model_css - mm.update(model) - else: - model = mm.new(modelName) - # Add Field for Evernote GUID: - # Note that this field is first because Anki requires the first field to be unique - evernote_guid_field = mm.newField(FIELDS.EVERNOTE_GUID) - evernote_guid_field['sticky'] = True - evernote_guid_field['font'] = 'Consolas' - evernote_guid_field['size'] = 10 - mm.addField(model, evernote_guid_field) - - # Add Standard Fields: - mm.addField(model, mm.newField(FIELDS.TITLE)) - - evernote_content_field = mm.newField(FIELDS.CONTENT) - evernote_content_field['size'] = 14 - mm.addField(model, evernote_content_field) - - evernote_see_also_field = mm.newField(FIELDS.SEE_ALSO) - evernote_see_also_field['size'] = 14 - mm.addField(model, evernote_see_also_field) - - evernote_extra_field = mm.newField(FIELDS.EXTRA) - evernote_extra_field['size'] = 12 - mm.addField(model, evernote_extra_field) - - evernote_toc_field = mm.newField(FIELDS.TOC) - evernote_toc_field['size'] = 10 - mm.addField(model, evernote_toc_field) - - evernote_outline_field = mm.newField(FIELDS.OUTLINE) - evernote_outline_field['size'] = 10 - mm.addField(model, evernote_outline_field) - - # Add USN to keep track of changes vs Evernote's servers - evernote_usn_field = mm.newField(FIELDS.UPDATE_SEQUENCE_NUM) - evernote_usn_field['font'] = 'Consolas' - evernote_usn_field['size'] = 10 - mm.addField(model, evernote_usn_field) - - # Add Templates - - if modelName is MODELS.DEFAULT or modelName is MODELS.REVERSIBLE: - # Add Default Template - default_template = mm.newTemplate(TEMPLATES.DEFAULT) - default_template['qfmt'] = templates['Front'] - default_template['afmt'] = templates['Back'] - mm.addTemplate(model, default_template) - if modelName is MODELS.REVERSE_ONLY or modelName is MODELS.REVERSIBLE: - # Add Reversed Template - reversed_template = mm.newTemplate(TEMPLATES.REVERSED) - reversed_template['qfmt'] = templates['Front'] - reversed_template['afmt'] = templates['Back'] - mm.addTemplate(model, reversed_template) - if modelName is MODELS.CLOZE: - # Add Cloze Template - cloze_template = mm.newTemplate(TEMPLATES.CLOZE) - cloze_template['qfmt'] = templates['Front'] - cloze_template['afmt'] = templates['Back'] - mm.addTemplate(model, cloze_template) - - # Update Sort field to Title (By default set to GUID since it is the first field) - model['sortf'] = 1 - - # Update Model CSS - model['css'] = model_css - - # Set Type to Cloze - if cloze: - model['type'] = MODELS.TYPES.CLOZE - - # Add Model to Collection - mm.add(model) - - # Add Model id to list - self.evernoteModels[modelName] = model['id'] - return forceRebuild - - def get_templates(self, forceRebuild=False): - if not self.templates or forceRebuild: - evernote_account_info = get_evernote_account_ids() - field_names = { - "Title": FIELDS.TITLE, "Content": FIELDS.CONTENT, "Extra": FIELDS.EXTRA, - "See Also": FIELDS.SEE_ALSO, "TOC": FIELDS.TOC, "Outline": FIELDS.OUTLINE, - "Evernote GUID Prefix": FIELDS.EVERNOTE_GUID_PREFIX, "Evernote GUID": FIELDS.EVERNOTE_GUID, - "Evernote UID": evernote_account_info.uid, "Evernote shard": evernote_account_info.shard - } - # Generate Front and Back Templates from HTML Template in anknotes' addon directory - self.templates = {"Front": file(FILES.ANCILLARY.TEMPLATE, 'r').read() % field_names} - self.templates["Back"] = self.templates["Front"].replace("<div id='Side-Front'>", "<div id='Side-Back'>") - return self.templates - - def add_evernote_models(self): - col = self.collection() - mm = col.models - self.evernoteModels = {} - - forceRebuild = self.add_evernote_model(mm, MODELS.DEFAULT) - self.add_evernote_model(mm, MODELS.REVERSE_ONLY, forceRebuild) - self.add_evernote_model(mm, MODELS.REVERSIBLE, forceRebuild) - self.add_evernote_model(mm, MODELS.CLOZE, forceRebuild, True) - - def setup_ancillary_files(self): - # Copy CSS file from anknotes addon directory to media directory - media_dir = re.sub("(?i)\.(anki2)$", ".media", self.collection().path) - if isinstance(media_dir, str): - media_dir = unicode(media_dir, sys.getfilesystemencoding()) - shutil.copy2(os.path.join(FOLDERS.ANCILLARY, FILES.ANCILLARY.CSS), os.path.join(media_dir, FILES.ANCILLARY.CSS)) - - def get_anki_fields_from_anki_note_id(self, a_id, fields_to_ignore=list()): - note = self.collection().getNote(a_id) - try: - items = note.items() - except: - log_error("Unable to get note items for Note ID: %d" % a_id) - raise - return get_dict_from_list(items, fields_to_ignore) - - def get_evernote_guids_from_anki_note_ids(self, ids=None): - if ids is None: - ids = self.get_anknotes_note_ids() - evernote_guids = [] - self.usns = {} - for a_id in ids: - fields = self.get_anki_fields_from_anki_note_id(a_id, [FIELDS.CONTENT]) - evernote_guid = get_evernote_guid_from_anki_fields(fields) - if not evernote_guid: continue - evernote_guids.append(evernote_guid) - log('Anki USN for Note %s is %s' % (evernote_guid, fields[FIELDS.UPDATE_SEQUENCE_NUM]), 'anki-usn') - if FIELDS.UPDATE_SEQUENCE_NUM in fields: - self.usns[evernote_guid] = fields[FIELDS.UPDATE_SEQUENCE_NUM] - else: - log(" ! get_evernote_guids_from_anki_note_ids: Note '%s' is missing USN!" % evernote_guid) - return evernote_guids - - def get_evernote_guids_and_anki_fields_from_anki_note_ids(self, ids=None): - if ids is None: - ids = self.get_anknotes_note_ids() - evernote_guids = {} - for a_id in ids: - fields = self.get_anki_fields_from_anki_note_id(a_id) - evernote_guid = get_evernote_guid_from_anki_fields(fields) - if evernote_guid: evernote_guids[evernote_guid] = fields - return evernote_guids - - def search_evernote_models_query(self): - query = "" - delimiter = "" - for mName, mid in self.evernoteModels.items(): - query += delimiter + "mid:" + str(mid) - delimiter = " OR " - return query - - def get_anknotes_note_ids(self, query_filter=""): - query = self.search_evernote_models_query() - if query_filter: - query = query_filter + " (%s)" % query - ids = self.collection().findNotes(query) - return ids - - def get_anki_note_from_evernote_guid(self, evernote_guid): - col = self.collection() - ids = col.findNotes(FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid) - # TODO: Ugly work around for a bug. Fix this later - if not ids: return None - if not ids[0]: return None - note = AnkiNote(col, None, ids[0]) - return note - - def get_anknotes_note_ids_by_tag(self, tag): - return self.get_anknotes_note_ids("tag:" + tag) - - def get_anknotes_note_ids_with_unadded_see_also(self): - return self.get_anknotes_note_ids('"See Also" "See_Also:"') - - def process_see_also_content(self, anki_note_ids): - count = 0 - count_update = 0 - max_count = len(anki_note_ids) - for a_id in anki_note_ids: - ankiNote = self.collection().getNote(a_id) - try: - items = ankiNote.items() - except: - log_error("Unable to get note items for Note ID: %d" % a_id) - raise - fields = {} - for key, value in items: - fields[key] = value - if not fields[FIELDS.SEE_ALSO]: - anki_note_prototype = AnkiNotePrototype(self, fields, ankiNote.tags, ankiNote, count=count, - count_update=count_update, max_count=max_count) - if anki_note_prototype.Fields[FIELDS.SEE_ALSO]: - log("Detected see also contents for Note '%s': %s" % ( - get_evernote_guid_from_anki_fields(fields), fields[FIELDS.TITLE])) - log(u" ::: %s " % strip_tags_and_new_lines(fields[FIELDS.SEE_ALSO])) - if anki_note_prototype.update_note(): - count_update += 1 - count += 1 - - def process_toc_and_outlines(self): - self.extract_links_from_toc() - self.insert_toc_into_see_also() - self.insert_toc_and_outline_contents_into_notes() - - def update_evernote_note_contents(self): - see_also_notes = ankDB().all("SELECT DISTINCT target_evernote_guid FROM %s WHERE 1" % TABLES.SEE_ALSO) - - - def insert_toc_into_see_also(self): - log = Logger(rm_path=True) - db = ankDB() - db._db.row_factory = None - results = db.all( - "SELECT s.source_evernote_guid, s.target_evernote_guid, n.title, n2.title FROM %s as s, %s as n, %s as n2 WHERE s.source_evernote_guid != s.target_evernote_guid AND n.guid = s.target_evernote_guid AND n2.guid = s.source_evernote_guid AND s.from_toc == 1 ORDER BY s.source_evernote_guid ASC, n.title ASC" % ( - TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES, TABLES.EVERNOTE.NOTES)) - all_guids = [x[0] for x in db.all("SELECT guid FROM %s WHERE tagNames NOT LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, TAGS.TOC))] - grouped_results = {} - - toc_titles = {} - for row in results: - key = row[0] - value = row[1] - if key not in all_guids: continue - toc_titles[value] = row[2] - if key not in grouped_results: grouped_results[key] = [row[3], []] - grouped_results[key][1].append(value) - # log_dump(grouped_results, 'grouped_results', 'insert_toc', timestamp=False) - action_title = 'INSERT TOCS INTO ANKI NOTES' - log.banner(action_title + ': %d NOTES' % len(grouped_results), 'insert_toc') - toc_separator = generate_evernote_span(u' | ', u'Links', u'See Also', bold=False) - count = 0 - count_update = 0 - max_count = len(grouped_results) - log.add(' <h1>INSERT TOC LINKS INTO ANKI NOTES: %d TOTAL NOTES</h1> <HR><BR><BR>' % max_count, 'see_also', timestamp=False, clear=True, - extension='htm') - logged_missing_anki_note=False - for source_guid, source_guid_info in sorted(grouped_results.items(), key=lambda s: s[1][0]): - toc_guids = source_guid_info[1] - note_title = source_guid_info[0] - ankiNote = self.get_anki_note_from_evernote_guid(source_guid) - if not ankiNote: - log.dump(toc_guids, 'Missing Anki Note for ' + source_guid, 'insert_toc', timestamp=False, crosspost_to_default=False) - if not logged_missing_anki_note: - log_error('%s: Missing Anki Note(s) for TOC entry. See insert_toc log for more details' % action_title) - logged_missing_anki_note = True - else: - fields = get_dict_from_list(ankiNote.items()) - see_also_html = fields[FIELDS.SEE_ALSO] - content_links = find_evernote_links_as_guids(fields[FIELDS.CONTENT]) - see_also_links = find_evernote_links_as_guids(see_also_html) - new_tocs = set(toc_guids) - set(see_also_links) - set(content_links) - log.dump([new_tocs, toc_guids, see_also_links, content_links], 'TOCs for %s' % fields[FIELDS.TITLE] + ' vs ' + note_title , 'insert_toc_new_tocs', crosspost_to_default=False) - new_toc_count = len(new_tocs) - if new_toc_count > 0: - see_also_count = len(see_also_links) - has_ol = u'<ol' in see_also_html - has_ul = u'<ul' in see_also_html - has_list = has_ol or has_ul - see_also_new = " " - flat_links = (new_toc_count + see_also_count < 3 and not has_list) - toc_delimiter = u' ' if see_also_count is 0 else toc_separator - for toc_guid in toc_guids: - toc_title = toc_titles[toc_guid] - if flat_links: - toc_title = u'[%s]' % toc_title - toc_link = generate_evernote_link(toc_guid, toc_title, value='TOC') - see_also_new += (toc_delimiter + toc_link) if flat_links else (u'\n<li>%s</li>' % toc_link) - toc_delimiter = toc_separator - if flat_links: - find_div_end = see_also_html.rfind('</div>') - if find_div_end > -1: - see_also_html = see_also_html[:find_div_end] + see_also_new + '\n' + see_also_html[find_div_end:] - see_also_new = '' - else: - see_also_toc_headers = {'ol': u'<br><div style="margin-top:5px;">\n%s</div><ol style="margin-top:3px;">' % generate_evernote_span( - '<u>TABLE OF CONTENTS</u>:', 'Levels', 'Auto TOC', escape=False)} - see_also_toc_headers['ul'] = see_also_toc_headers['ol'].replace('<ol ', '<ul ') - - if see_also_toc_headers['ul'] in see_also_html: - find_ul_end = see_also_html.rfind('</ul>') - see_also_html = see_also_html[:find_ul_end] + '</ol>' + see_also_html[find_ul_end + 5:] - see_also_html = see_also_html.replace(see_also_toc_headers['ul'], see_also_toc_headers['ol']) - if see_also_toc_headers['ol'] in see_also_html: - find_ol_end = see_also_html.rfind('</ol>') - see_also_html = see_also_html[:find_ol_end] + see_also_new + '\n' + see_also_html[find_ol_end:] - see_also_new = '' - else: - header_type = 'ul' if new_toc_count is 1 else 'ol' - see_also_new = see_also_toc_headers[header_type] + u'%s\n</%s>' % (see_also_new, header_type) - if see_also_count == 0: - see_also_html = generate_evernote_span(u'See Also:', 'Links', 'See Also') - see_also_html += see_also_new - see_also_html = see_also_html.replace('<ol>', '<ol style="margin-top:3px;">') - log.add('<h3>%s</h3><br>' % generate_evernote_span(fields[FIELDS.TITLE], 'Links', - 'TOC') + see_also_html + u'<HR>', 'see_also', - timestamp=False, extension='htm') - fields[FIELDS.SEE_ALSO] = see_also_html.replace('evernote:///', 'evernote://') - anki_note_prototype = AnkiNotePrototype(self, fields, ankiNote.tags, ankiNote, count=count, - count_update=count_update, max_count=max_count) - anki_note_prototype._log_update_if_unchanged_ = (new_toc_count > 0) - if anki_note_prototype.update_note(): - count_update += 1 - count += 1 - db._db.row_factory = sqlite.Row - - def extract_links_from_toc(self): - query_update_toc_links = "UPDATE %s SET is_toc = 1 WHERE " % TABLES.SEE_ALSO - delimiter = "" - ankDB().setrowfactory() - toc_entries = ankDB().execute("SELECT * FROM %s WHERE tagNames LIKE '%%,#TOC,%%'" % TABLES.EVERNOTE.NOTES) - for toc_entry in toc_entries: - toc_evernote_guid = toc_entry['guid'] - toc_link_title = toc_entry['title'] - toc_link_html = generate_evernote_span(toc_link_title, 'Links', 'TOC') - for enLink in find_evernote_links(toc_entry['content']): - target_evernote_guid = enLink.Guid - link_number = 1 + ankDB().scalar("select COUNT(*) from %s WHERE source_evernote_guid = '%s' " % ( - TABLES.SEE_ALSO, target_evernote_guid)) - query = """INSERT INTO `%s`(`source_evernote_guid`, `number`, `uid`, `shard`, `target_evernote_guid`, `html`, `title`, `from_toc`, `is_toc`) SELECT '%s', %d, %d, '%s', '%s', '%s', '%s', 1, 1 FROM `%s` WHERE NOT EXISTS (SELECT * FROM `%s` WHERE `source_evernote_guid`='%s' AND `target_evernote_guid`='%s') LIMIT 1 """ % ( - TABLES.SEE_ALSO, target_evernote_guid, link_number, enLink.Uid, enLink.Shard, toc_evernote_guid, - toc_link_html.replace(u'\'', u'\'\''), toc_link_title.replace(u'\'', u'\'\''), TABLES.SEE_ALSO, - TABLES.SEE_ALSO, target_evernote_guid, toc_evernote_guid) - log_sql('UPDATE_ANKI_DB: Add See Also Link: SQL Query: ' + query) - ankDB().execute(query) - query_update_toc_links += delimiter + "target_evernote_guid = '%s'" % toc_evernote_guid - delimiter = " OR " - ankDB().execute(query_update_toc_links) - ankDB().commit() - - def insert_toc_and_outline_contents_into_notes(self): - linked_notes_fields = {} - source_guids = ankDB().list( - "select DISTINCT source_evernote_guid from %s WHERE is_toc = 1 ORDER BY source_evernote_guid ASC" % TABLES.SEE_ALSO) - source_guids_count = len(source_guids) - i = 0 - for source_guid in source_guids: - i += 1 - note = self.get_anki_note_from_evernote_guid(source_guid) - if not note: continue - if TAGS.TOC in note.tags: continue - for fld in note._model['flds']: - if FIELDS.TITLE in fld.get('name'): - note_title = note.fields[fld.get('ord')] - continue - if not note_title: - log_error("Could not find note title for %s for insert_toc_and_outline_contents_into_notes" % note.guid) - continue - note_toc = "" - note_outline = "" - toc_header = "" - outline_header = "" - toc_count = 0 - outline_count = 0 - toc_and_outline_links = ankDB().execute( - "select target_evernote_guid, is_toc, is_outline from %s WHERE source_evernote_guid = '%s' AND (is_toc = 1 OR is_outline = 1) ORDER BY number ASC" % ( - TABLES.SEE_ALSO, source_guid)) - for target_evernote_guid, is_toc, is_outline in toc_and_outline_links: - if target_evernote_guid in linked_notes_fields: - linked_note_contents = linked_notes_fields[target_evernote_guid][FIELDS.CONTENT] - linked_note_title = linked_notes_fields[target_evernote_guid][FIELDS.TITLE] - else: - linked_note = self.get_anki_note_from_evernote_guid(target_evernote_guid) - if not linked_note: continue - linked_note_contents = u"" - for fld in linked_note._model['flds']: - if FIELDS.CONTENT in fld.get('name'): - linked_note_contents = linked_note.fields[fld.get('ord')] - elif FIELDS.TITLE in fld.get('name'): - linked_note_title = linked_note.fields[fld.get('ord')] - if linked_note_contents: - linked_notes_fields[target_evernote_guid] = { - FIELDS.TITLE: linked_note_title, - FIELDS.CONTENT: linked_note_contents - } - if linked_note_contents: - if isinstance(linked_note_contents, str): - linked_note_contents = unicode(linked_note_contents, 'utf-8') - if (is_toc or is_outline) and (toc_count + outline_count is 0): - log(" > [%3d/%3d] Found TOC/Outline for Note '%s': %s" % (i, source_guids_count, source_guid, note_title), 'See Also') - if is_toc: - toc_count += 1 - if toc_count is 1: - toc_header = "<span class='header'>TABLE OF CONTENTS</span>: 1. <span class='header'>%s</span>" % linked_note_title - else: - toc_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % ( - toc_count, linked_note_title) - note_toc += "<br><hr>" - - note_toc += linked_note_contents - log(" > Appending TOC #%d contents" % toc_count, 'See Also') - else: - outline_count += 1 - if outline_count is 1: - outline_header = "<span class='header'>OUTLINE</span>: 1. <span class='header'>%s</span>" % linked_note_title - else: - outline_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % ( - outline_count, linked_note_title) - note_outline += "<BR><HR>" - - note_outline += linked_note_contents - log(" > Appending Outline #%d contents" % outline_count, 'See Also') - - if outline_count + toc_count > 0: - if outline_count > 1: - note_outline = "<span class='Outline'>%s</span><BR><BR>" % outline_header + note_outline - if toc_count > 1: - note_toc = "<span class='TOC'>%s</span><BR><BR>" % toc_header + note_toc - for fld in note._model['flds']: - if FIELDS.TOC in fld.get('name'): - note.fields[fld.get('ord')] = note_toc - elif FIELDS.OUTLINE in fld.get('name'): - note.fields[fld.get('ord')] = note_outline - log(" > Flushing Note \r\n", 'See Also') - note.flush() - - def start_editing(self): - self.window().requireReset() - - def stop_editing(self): - if self.collection(): - self.window().maybeReset() - - @staticmethod - def window(): - """ - :rtype : AnkiQt - :return: - """ - return aqt.mw - - def collection(self): - return self.window().col - - def models(self): - return self.collection().models - - def decks(self): - return self.collection().decks + def __init__(self): + self.deck = None + self.templates = None + + @staticmethod + def get_notebook_guid_from_ankdb(evernote_guid): + return ankDB().scalar("SELECT notebookGuid FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, evernote_guid)) + + def get_deck_name_from_evernote_notebook(self, notebookGuid, deck=None): + if not deck: + deck = self.deck if self.deck else "" + if not hasattr(self, 'notebook_data'): + self.notebook_data = {} + if not notebookGuid in self.notebook_data: + # log_error("Unexpected error: Notebook GUID '%s' could not be found in notebook data: %s" % (notebookGuid, str(self.notebook_data))) + notebook = EvernoteNotebook(fetch_guid=notebookGuid) + if not notebook.success: + log_error( + " get_deck_name_from_evernote_notebook FATAL ERROR: UNABLE TO FIND NOTEBOOK '%s'. " % notebookGuid) + return None + # log("Getting notebook info: %s" % str(notebook)) + self.notebook_data[notebookGuid] = notebook + notebook = self.notebook_data[notebookGuid] + if notebook.Stack: + deck += u'::' + notebook.Stack + deck += "::" + notebook.Name + deck = deck.replace(": ", "::") + if deck[:2] == '::': + deck = deck[2:] + return deck + + def update_evernote_notes(self, evernote_notes, log_update_if_unchanged=True): + """ + Update Notes in Anki Database + :type evernote_notes: list[EvernoteNotePrototype.EvernoteNotePrototype] + :rtype : int + :param evernote_notes: List of EvernoteNote returned from server or local db + :param log_update_if_unchanged: + :return: Count of notes successfully updated + """ + return self.add_evernote_notes(evernote_notes, True, log_update_if_unchanged=log_update_if_unchanged) + + def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchanged=True): + """ + Add Notes to or Update Notes in Anki Database + :param evernote_notes: + :param update: + :param log_update_if_unchanged: + :type evernote_notes: list[EvernoteNotePrototype.EvernoteNotePrototype] + :type update: bool + :return: Count of notes successfully added or updated + """ + count_update = 0 + action_str = ['ADDING', 'UPDATING'][update] + tmr = stopwatch.Timer(len(evernote_notes), 100, + infoStr=action_str + " EVERNOTE NOTE(S) %s ANKI" % ['TO', 'IN'][update], + label='AddEvernoteNotes') + + # if tmr.willReportProgress: + # log_banner(), tmr.label, append_newline=False) + for ankiNote in evernote_notes: + try: + title = ankiNote.FullTitle + content = ankiNote.Content + if isinstance(content, str): + content = unicode(content, 'utf-8') + anki_field_info = { + FIELDS.TITLE: title, + FIELDS.CONTENT: content, + FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + ankiNote.Guid, + FIELDS.UPDATE_SEQUENCE_NUM: str(ankiNote.UpdateSequenceNum), + FIELDS.SEE_ALSO: u'' + } + except: + log_error("Unable to set field info for: Note '%s': '%s'" % (ankiNote.Title, ankiNote.Guid)) + log_dump(ankiNote.Content, " NOTE CONTENTS ") + # log_dump(ankiNote.Content.encode('utf-8'), " NOTE CONTENTS ") + raise + if tmr.step(): + log(['Adding', 'Updating'][update] + " Note %5s: %s: %s" % ('#' + str(tmr.count), tmr.progress, title), + tmr.label) + baseNote = None + if update: + baseNote = self.get_anki_note_from_evernote_guid(ankiNote.Guid) + if not baseNote: + log_error('Updating note %s: COULD NOT FIND BASE NOTE FOR ANKI NOTE ID' % ankiNote.Guid) + tmr.reportStatus(EvernoteAPIStatus.MissingDataError) + continue + if ankiNote.Tags is None: + log_error("Could note find tags object for note %s: %s. " % (ankiNote.Guid, ankiNote.FullTitle)) + tmr.reportStatus(EvernoteAPIStatus.MissingDataError) + continue + anki_note_prototype = AnkiNotePrototype(self, anki_field_info, ankiNote.TagNames, baseNote, + notebookGuid=ankiNote.NotebookGuid, count=tmr.count, + count_update=tmr.counts.success, max_count=tmr.counts.max.val) + anki_note_prototype._log_update_if_unchanged_ = log_update_if_unchanged + anki_result = anki_note_prototype.update_note() if update else anki_note_prototype.add_note() + if anki_result != -1: + tmr.reportSuccess(update, True) + else: + tmr.reportError(True) + log("ANKI ERROR WHILE %s EVERNOTE NOTES: " % action_str + str(anki_result), 'AddEvernoteNotes-Error') + tmr.Report() + return tmr.counts.success + + def delete_anki_cards(self, evernote_guids): + col = self.collection() + card_ids = [] + for evernote_guid in evernote_guids: + card_ids += mw.col.findCards(FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid) + col.remCards(card_ids) + return len(card_ids) + + @staticmethod + def get_evernote_model_styles(): + if MODELS.OPTIONS.IMPORT_STYLES: return '@import url("%s");' % FILES.ANCILLARY.CSS + return file(os.path.join(FOLDERS.ANCILLARY, FILES.ANCILLARY.CSS), 'r').read() + + def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): + model = mm.byName(modelName) + model_css = self.get_evernote_model_styles() + templates = self.get_templates(modelName == MODELS.DEFAULT) + if model and modelName is MODELS.DEFAULT: + front = model['tmpls'][0]['qfmt'] + evernote_account_info = get_evernote_account_ids() + if not evernote_account_info.Valid: + info = ankDB().first( + "SELECT uid, shard, COUNT(uid) as c1, COUNT(shard) as c2 from %s GROUP BY uid, shard ORDER BY c1 DESC, c2 DESC LIMIT 1" % TABLES.SEE_ALSO) + if info and evernote_account_info.update(info[0], info[1]): forceRebuild = True + if evernote_account_info.Valid: + if not "evernote_uid = '%s'" % evernote_account_info.uid in front or not "evernote_shard = '%s'" % evernote_account_info.shard in front: forceRebuild = True + if model['css'] != model_css: forceRebuild = True + if model['tmpls'][0]['qfmt'] != templates['Front']: forceRebuild = True + if not model or forceRebuild: + if model: + for t in model['tmpls']: + t['qfmt'] = templates['Front'] + t['afmt'] = templates['Back'] + model['css'] = model_css + mm.update(model) + else: + model = mm.new(modelName) + # Add Field for Evernote GUID: + # Note that this field is first because Anki requires the first field to be unique + evernote_guid_field = mm.newField(FIELDS.EVERNOTE_GUID) + evernote_guid_field['sticky'] = True + evernote_guid_field['font'] = 'Consolas' + evernote_guid_field['size'] = 10 + mm.addField(model, evernote_guid_field) + + # Add Standard Fields: + mm.addField(model, mm.newField(FIELDS.TITLE)) + + evernote_content_field = mm.newField(FIELDS.CONTENT) + evernote_content_field['size'] = 14 + mm.addField(model, evernote_content_field) + + evernote_see_also_field = mm.newField(FIELDS.SEE_ALSO) + evernote_see_also_field['size'] = 14 + mm.addField(model, evernote_see_also_field) + + evernote_extra_field = mm.newField(FIELDS.EXTRA) + evernote_extra_field['size'] = 12 + mm.addField(model, evernote_extra_field) + + evernote_toc_field = mm.newField(FIELDS.TOC) + evernote_toc_field['size'] = 10 + mm.addField(model, evernote_toc_field) + + evernote_outline_field = mm.newField(FIELDS.OUTLINE) + evernote_outline_field['size'] = 10 + mm.addField(model, evernote_outline_field) + + # Add USN to keep track of changes vs Evernote's servers + evernote_usn_field = mm.newField(FIELDS.UPDATE_SEQUENCE_NUM) + evernote_usn_field['font'] = 'Consolas' + evernote_usn_field['size'] = 10 + mm.addField(model, evernote_usn_field) + + # Add Templates + + if modelName is MODELS.DEFAULT or modelName is MODELS.REVERSIBLE: + # Add Default Template + default_template = mm.newTemplate(TEMPLATES.DEFAULT) + default_template['qfmt'] = templates['Front'] + default_template['afmt'] = templates['Back'] + mm.addTemplate(model, default_template) + if modelName is MODELS.REVERSE_ONLY or modelName is MODELS.REVERSIBLE: + # Add Reversed Template + reversed_template = mm.newTemplate(TEMPLATES.REVERSED) + reversed_template['qfmt'] = templates['Front'] + reversed_template['afmt'] = templates['Back'] + mm.addTemplate(model, reversed_template) + if modelName is MODELS.CLOZE: + # Add Cloze Template + cloze_template = mm.newTemplate(TEMPLATES.CLOZE) + cloze_template['qfmt'] = templates['Front'] + cloze_template['afmt'] = templates['Back'] + mm.addTemplate(model, cloze_template) + + # Update Sort field to Title (By default set to GUID since it is the first field) + model['sortf'] = 1 + + # Update Model CSS + model['css'] = model_css + + # Set Type to Cloze + if cloze: + model['type'] = MODELS.TYPES.CLOZE + + # Add Model to Collection + mm.add(model) + + # Add Model id to list + self.evernoteModels[modelName] = model['id'] + return forceRebuild + + def get_templates(self, forceRebuild=False): + if not self.templates or forceRebuild: + evernote_account_info = get_evernote_account_ids() + field_names = { + "Title": FIELDS.TITLE, "Content": FIELDS.CONTENT, "Extra": FIELDS.EXTRA, + "See Also": FIELDS.SEE_ALSO, "TOC": FIELDS.TOC, "Outline": FIELDS.OUTLINE, + "Evernote GUID Prefix": FIELDS.EVERNOTE_GUID_PREFIX, "Evernote GUID": FIELDS.EVERNOTE_GUID, + "Evernote UID": evernote_account_info.uid, "Evernote shard": evernote_account_info.shard + } + # Generate Front and Back Templates from HTML Template in anknotes' addon directory + self.templates = {"Front": file(FILES.ANCILLARY.TEMPLATE, 'r').read() % field_names} + self.templates["Back"] = self.templates["Front"].replace("<div id='Side-Front'>", "<div id='Side-Back'>") + return self.templates + + def add_evernote_models(self): + col = self.collection() + mm = col.models + self.evernoteModels = {} + + forceRebuild = self.add_evernote_model(mm, MODELS.DEFAULT) + self.add_evernote_model(mm, MODELS.REVERSE_ONLY, forceRebuild) + self.add_evernote_model(mm, MODELS.REVERSIBLE, forceRebuild) + self.add_evernote_model(mm, MODELS.CLOZE, forceRebuild, True) + + def setup_ancillary_files(self): + # Copy CSS file from anknotes addon directory to media directory + media_dir = re.sub("(?i)\.(anki2)$", ".media", self.collection().path) + if isinstance(media_dir, str): + media_dir = unicode(media_dir, sys.getfilesystemencoding()) + shutil.copy2(os.path.join(FOLDERS.ANCILLARY, FILES.ANCILLARY.CSS), os.path.join(media_dir, FILES.ANCILLARY.CSS)) + + def get_anki_fields_from_anki_note_id(self, a_id, fields_to_ignore=list()): + note = self.collection().getNote(a_id) + try: + items = note.items() + except: + log_error("Unable to get note items for Note ID: %d" % a_id) + raise + return get_dict_from_list(items, fields_to_ignore) + + def get_evernote_guids_from_anki_note_ids(self, ids=None): + if ids is None: + ids = self.get_anknotes_note_ids() + evernote_guids = [] + self.usns = {} + for a_id in ids: + fields = self.get_anki_fields_from_anki_note_id(a_id, [FIELDS.CONTENT]) + evernote_guid = get_evernote_guid_from_anki_fields(fields) + if not evernote_guid: continue + evernote_guids.append(evernote_guid) + log('Anki USN for Note %s is %s' % (evernote_guid, fields[FIELDS.UPDATE_SEQUENCE_NUM]), 'anki-usn') + if FIELDS.UPDATE_SEQUENCE_NUM in fields: + self.usns[evernote_guid] = fields[FIELDS.UPDATE_SEQUENCE_NUM] + else: + log(" ! get_evernote_guids_from_anki_note_ids: Note '%s' is missing USN!" % evernote_guid) + return evernote_guids + + def get_evernote_guids_and_anki_fields_from_anki_note_ids(self, ids=None): + if ids is None: + ids = self.get_anknotes_note_ids() + evernote_guids = {} + for a_id in ids: + fields = self.get_anki_fields_from_anki_note_id(a_id) + evernote_guid = get_evernote_guid_from_anki_fields(fields) + if evernote_guid: evernote_guids[evernote_guid] = fields + return evernote_guids + + def search_evernote_models_query(self): + query = "" + delimiter = "" + for mName, mid in self.evernoteModels.items(): + query += delimiter + "mid:" + str(mid) + delimiter = " OR " + return query + + def get_anknotes_note_ids(self, query_filter=""): + query = self.search_evernote_models_query() + if query_filter: + query = query_filter + " (%s)" % query + ids = self.collection().findNotes(query) + return ids + + def get_anki_note_from_evernote_guid(self, evernote_guid): + col = self.collection() + ids = col.findNotes(FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid) + # TODO: Ugly work around for a bug. Fix this later + if not ids: return None + if not ids[0]: return None + note = AnkiNote(col, None, ids[0]) + return note + + def get_anknotes_note_ids_by_tag(self, tag): + return self.get_anknotes_note_ids("tag:" + tag) + + def get_anknotes_note_ids_with_unadded_see_also(self): + return self.get_anknotes_note_ids('"See Also" "See_Also:"') + + def process_see_also_content(self, anki_note_ids): + count = 0 + count_update = 0 + max_count = len(anki_note_ids) + for a_id in anki_note_ids: + ankiNote = self.collection().getNote(a_id) + try: + items = ankiNote.items() + except: + log_error("Unable to get note items for Note ID: %d" % a_id) + raise + fields = {} + for key, value in items: + fields[key] = value + if not fields[FIELDS.SEE_ALSO]: + anki_note_prototype = AnkiNotePrototype(self, fields, ankiNote.tags, ankiNote, count=count, + count_update=count_update, max_count=max_count) + if anki_note_prototype.Fields[FIELDS.SEE_ALSO]: + log("Detected see also contents for Note '%s': %s" % ( + get_evernote_guid_from_anki_fields(fields), fields[FIELDS.TITLE])) + log(u" ::: %s " % strip_tags_and_new_lines(fields[FIELDS.SEE_ALSO])) + if anki_note_prototype.update_note(): + count_update += 1 + count += 1 + + def process_toc_and_outlines(self): + self.extract_links_from_toc() + self.insert_toc_into_see_also() + self.insert_toc_and_outline_contents_into_notes() + + def update_evernote_note_contents(self): + see_also_notes = ankDB().all("SELECT DISTINCT target_evernote_guid FROM %s WHERE 1" % TABLES.SEE_ALSO) + + + def insert_toc_into_see_also(self): + log = Logger(rm_path=True) + db = ankDB() + db._db.row_factory = None + results = db.all( + "SELECT s.source_evernote_guid, s.target_evernote_guid, n.title, n2.title FROM %s as s, %s as n, %s as n2 WHERE s.source_evernote_guid != s.target_evernote_guid AND n.guid = s.target_evernote_guid AND n2.guid = s.source_evernote_guid AND s.from_toc == 1 ORDER BY s.source_evernote_guid ASC, n.title ASC" % ( + TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES, TABLES.EVERNOTE.NOTES)) + all_guids = [x[0] for x in db.all( + "SELECT guid FROM %s WHERE tagNames NOT LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, TAGS.TOC))] + grouped_results = {} + + toc_titles = {} + for row in results: + key = row[0] + value = row[1] + if key not in all_guids: continue + toc_titles[value] = row[2] + if key not in grouped_results: grouped_results[key] = [row[3], []] + grouped_results[key][1].append(value) + # log_dump(grouped_results, 'grouped_results', 'insert_toc', timestamp=False) + action_title = 'INSERT TOCS INTO ANKI NOTES' + log.banner(action_title + ': %d NOTES' % len(grouped_results), 'insert_toc') + toc_separator = generate_evernote_span(u' | ', u'Links', u'See Also', bold=False) + count = 0 + count_update = 0 + max_count = len(grouped_results) + log.add(' <h1>INSERT TOC LINKS INTO ANKI NOTES: %d TOTAL NOTES</h1> <HR><BR><BR>' % max_count, + 'see_also', timestamp=False, clear=True, + extension='htm') + logged_missing_anki_note = False + for source_guid, source_guid_info in sorted(grouped_results.items(), key=lambda s: s[1][0]): + toc_guids = source_guid_info[1] + note_title = source_guid_info[0] + ankiNote = self.get_anki_note_from_evernote_guid(source_guid) + if not ankiNote: + log.dump(toc_guids, 'Missing Anki Note for ' + source_guid, 'insert_toc', timestamp=False, + crosspost_to_default=False) + if not logged_missing_anki_note: + log_error( + '%s: Missing Anki Note(s) for TOC entry. See insert_toc log for more details' % action_title) + logged_missing_anki_note = True + else: + fields = get_dict_from_list(ankiNote.items()) + see_also_html = fields[FIELDS.SEE_ALSO] + content_links = find_evernote_links_as_guids(fields[FIELDS.CONTENT]) + see_also_links = find_evernote_links_as_guids(see_also_html) + new_tocs = set(toc_guids) - set(see_also_links) - set(content_links) + log.dump([new_tocs, toc_guids, see_also_links, content_links], + 'TOCs for %s' % fields[FIELDS.TITLE] + ' vs ' + note_title, 'insert_toc_new_tocs', + crosspost_to_default=False) + new_toc_count = len(new_tocs) + if new_toc_count > 0: + see_also_count = len(see_also_links) + has_ol = u'<ol' in see_also_html + has_ul = u'<ul' in see_also_html + has_list = has_ol or has_ul + see_also_new = " " + flat_links = (new_toc_count + see_also_count < 3 and not has_list) + toc_delimiter = u' ' if see_also_count is 0 else toc_separator + for toc_guid in toc_guids: + toc_title = toc_titles[toc_guid] + if flat_links: + toc_title = u'[%s]' % toc_title + toc_link = generate_evernote_link(toc_guid, toc_title, value='TOC') + see_also_new += (toc_delimiter + toc_link) if flat_links else (u'\n<li>%s</li>' % toc_link) + toc_delimiter = toc_separator + if flat_links: + find_div_end = see_also_html.rfind('</div>') + if find_div_end > -1: + see_also_html = see_also_html[:find_div_end] + see_also_new + '\n' + see_also_html[ + find_div_end:] + see_also_new = '' + else: + see_also_toc_headers = { + 'ol': u'<br><div style="margin-top:5px;">\n%s</div><ol style="margin-top:3px;">' % generate_evernote_span( + '<u>TABLE OF CONTENTS</u>:', 'Levels', 'Auto TOC', escape=False)} + see_also_toc_headers['ul'] = see_also_toc_headers['ol'].replace('<ol ', '<ul ') + + if see_also_toc_headers['ul'] in see_also_html: + find_ul_end = see_also_html.rfind('</ul>') + see_also_html = see_also_html[:find_ul_end] + '</ol>' + see_also_html[find_ul_end + 5:] + see_also_html = see_also_html.replace(see_also_toc_headers['ul'], + see_also_toc_headers['ol']) + if see_also_toc_headers['ol'] in see_also_html: + find_ol_end = see_also_html.rfind('</ol>') + see_also_html = see_also_html[:find_ol_end] + see_also_new + '\n' + see_also_html[ + find_ol_end:] + see_also_new = '' + else: + header_type = 'ul' if new_toc_count is 1 else 'ol' + see_also_new = see_also_toc_headers[header_type] + u'%s\n</%s>' % ( + see_also_new, header_type) + if see_also_count == 0: + see_also_html = generate_evernote_span(u'See Also:', 'Links', 'See Also') + see_also_html += see_also_new + see_also_html = see_also_html.replace('<ol>', '<ol style="margin-top:3px;">') + log.add('<h3>%s</h3><br>' % generate_evernote_span(fields[FIELDS.TITLE], 'Links', + 'TOC') + see_also_html + u'<HR>', 'see_also', + timestamp=False, extension='htm') + fields[FIELDS.SEE_ALSO] = see_also_html.replace('evernote:///', 'evernote://') + anki_note_prototype = AnkiNotePrototype(self, fields, ankiNote.tags, ankiNote, count=count, + count_update=count_update, max_count=max_count) + anki_note_prototype._log_update_if_unchanged_ = (new_toc_count > 0) + if anki_note_prototype.update_note(): + count_update += 1 + count += 1 + db._db.row_factory = sqlite.Row + + def extract_links_from_toc(self): + query_update_toc_links = "UPDATE %s SET is_toc = 1 WHERE " % TABLES.SEE_ALSO + delimiter = "" + ankDB().setrowfactory() + toc_entries = ankDB().execute("SELECT * FROM %s WHERE tagNames LIKE '%%,#TOC,%%'" % TABLES.EVERNOTE.NOTES) + for toc_entry in toc_entries: + toc_evernote_guid = toc_entry['guid'] + toc_link_title = toc_entry['title'] + toc_link_html = generate_evernote_span(toc_link_title, 'Links', 'TOC') + for enLink in find_evernote_links(toc_entry['content']): + target_evernote_guid = enLink.Guid + link_number = 1 + ankDB().scalar("select COUNT(*) from %s WHERE source_evernote_guid = '%s' " % ( + TABLES.SEE_ALSO, target_evernote_guid)) + query = """INSERT INTO `%s`(`source_evernote_guid`, `number`, `uid`, `shard`, `target_evernote_guid`, `html`, `title`, `from_toc`, `is_toc`) SELECT '%s', %d, %d, '%s', '%s', '%s', '%s', 1, 1 FROM `%s` WHERE NOT EXISTS (SELECT * FROM `%s` WHERE `source_evernote_guid`='%s' AND `target_evernote_guid`='%s') LIMIT 1 """ % ( + TABLES.SEE_ALSO, target_evernote_guid, link_number, enLink.Uid, enLink.Shard, toc_evernote_guid, + toc_link_html.replace(u'\'', u'\'\''), toc_link_title.replace(u'\'', u'\'\''), TABLES.SEE_ALSO, + TABLES.SEE_ALSO, target_evernote_guid, toc_evernote_guid) + log_sql('UPDATE_ANKI_DB: Add See Also Link: SQL Query: ' + query) + ankDB().execute(query) + query_update_toc_links += delimiter + "target_evernote_guid = '%s'" % toc_evernote_guid + delimiter = " OR " + ankDB().execute(query_update_toc_links) + ankDB().commit() + + def insert_toc_and_outline_contents_into_notes(self): + linked_notes_fields = {} + source_guids = ankDB().list( + "select DISTINCT source_evernote_guid from %s WHERE is_toc = 1 ORDER BY source_evernote_guid ASC" % TABLES.SEE_ALSO) + source_guids_count = len(source_guids) + i = 0 + for source_guid in source_guids: + i += 1 + note = self.get_anki_note_from_evernote_guid(source_guid) + if not note: continue + if TAGS.TOC in note.tags: continue + for fld in note._model['flds']: + if FIELDS.TITLE in fld.get('name'): + note_title = note.fields[fld.get('ord')] + continue + if not note_title: + log_error("Could not find note title for %s for insert_toc_and_outline_contents_into_notes" % note.guid) + continue + note_toc = "" + note_outline = "" + toc_header = "" + outline_header = "" + toc_count = 0 + outline_count = 0 + toc_and_outline_links = ankDB().execute( + "select target_evernote_guid, is_toc, is_outline from %s WHERE source_evernote_guid = '%s' AND (is_toc = 1 OR is_outline = 1) ORDER BY number ASC" % ( + TABLES.SEE_ALSO, source_guid)) + for target_evernote_guid, is_toc, is_outline in toc_and_outline_links: + if target_evernote_guid in linked_notes_fields: + linked_note_contents = linked_notes_fields[target_evernote_guid][FIELDS.CONTENT] + linked_note_title = linked_notes_fields[target_evernote_guid][FIELDS.TITLE] + else: + linked_note = self.get_anki_note_from_evernote_guid(target_evernote_guid) + if not linked_note: continue + linked_note_contents = u"" + for fld in linked_note._model['flds']: + if FIELDS.CONTENT in fld.get('name'): + linked_note_contents = linked_note.fields[fld.get('ord')] + elif FIELDS.TITLE in fld.get('name'): + linked_note_title = linked_note.fields[fld.get('ord')] + if linked_note_contents: + linked_notes_fields[target_evernote_guid] = { + FIELDS.TITLE: linked_note_title, + FIELDS.CONTENT: linked_note_contents + } + if linked_note_contents: + if isinstance(linked_note_contents, str): + linked_note_contents = unicode(linked_note_contents, 'utf-8') + if (is_toc or is_outline) and (toc_count + outline_count is 0): + log(" > [%3d/%3d] Found TOC/Outline for Note '%s': %s" % ( + i, source_guids_count, source_guid, note_title), 'See Also') + if is_toc: + toc_count += 1 + if toc_count is 1: + toc_header = "<span class='header'>TABLE OF CONTENTS</span>: 1. <span class='header'>%s</span>" % linked_note_title + else: + toc_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % ( + toc_count, linked_note_title) + note_toc += "<br><hr>" + + note_toc += linked_note_contents + log(" > Appending TOC #%d contents" % toc_count, 'See Also') + else: + outline_count += 1 + if outline_count is 1: + outline_header = "<span class='header'>OUTLINE</span>: 1. <span class='header'>%s</span>" % linked_note_title + else: + outline_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % ( + outline_count, linked_note_title) + note_outline += "<BR><HR>" + + note_outline += linked_note_contents + log(" > Appending Outline #%d contents" % outline_count, 'See Also') + + if outline_count + toc_count > 0: + if outline_count > 1: + note_outline = "<span class='Outline'>%s</span><BR><BR>" % outline_header + note_outline + if toc_count > 1: + note_toc = "<span class='TOC'>%s</span><BR><BR>" % toc_header + note_toc + for fld in note._model['flds']: + if FIELDS.TOC in fld.get('name'): + note.fields[fld.get('ord')] = note_toc + elif FIELDS.OUTLINE in fld.get('name'): + note.fields[fld.get('ord')] = note_outline + log(" > Flushing Note \r\n", 'See Also') + note.flush() + + def start_editing(self): + self.window().requireReset() + + def stop_editing(self): + if self.collection(): + self.window().maybeReset() + + @staticmethod + def window(): + """ + :rtype : AnkiQt + :return: + """ + return aqt.mw + + def collection(self): + return self.window().col + + def models(self): + return self.collection().models + + def decks(self): + return self.collection().decks diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index b1aa919..9035405 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -8,539 +8,575 @@ try: - import anki - from anki.notes import Note as AnkiNote - from aqt import mw + import anki + from anki.notes import Note as AnkiNote + from aqt import mw except: - pass + pass def get_self_referential_fmap(): - fmap = {} - for i in range(0, len(FIELDS.LIST)): - fmap[i] = i - return fmap + fmap = {} + for i in range(0, len(FIELDS.LIST)): + fmap[i] = i + return fmap class AnkiNotePrototype: - Anki = None - """:type : anknotes.Anki.Anki """ - BaseNote = None - """:type : AnkiNote """ - enNote = None - """:type: EvernoteNotePrototype.EvernoteNotePrototype""" - Fields = {} - """:type : dict[str, str]""" - Tags = [] - """:type : list[str]""" - ModelName = None - """:type : str""" - # Guid = "" - # """:type : str""" - NotebookGuid = "" - """:type : str""" - __cloze_count__ = 0 - - class Counts: - Updated = 0 - Current = 0 - Max = 1 - - OriginalGuid = None - """:type : str""" - Changed = False - _unprocessed_content_ = "" - _unprocessed_see_also_ = "" - _log_update_if_unchanged_ = True - - @property - def Guid(self): - return get_evernote_guid_from_anki_fields(self.Fields) - - def __init__(self, anki=None, fields=None, tags=None, base_note=None, notebookGuid=None, count=-1, count_update=0, - max_count=1, counts=None, light_processing=False, enNote=None): - """ - Create Anki Note Prototype Class from fields or Base Anki Note - :param anki: Anki: Anknotes Main Class Instance - :type anki: anknotes.Anki.Anki - :param fields: Dict of Fields - :param tags: List of Tags - :type tags : list[str] - :param base_note: Base Anki Note if Updating an Existing Note - :type base_note : anki.notes.Note - :param enNote: Base Evernote Note Prototype from Anknotes DB, usually used just to process a note's contents - :type enNote : EvernoteNotePrototype.EvernoteNotePrototype - :param notebookGuid: - :param count: - :param count_update: - :param max_count: - :param counts: AnkiNotePrototype.Counts if being used to add/update multiple notes - :type counts : AnkiNotePrototype.Counts - :return: AnkiNotePrototype - """ - self.light_processing = light_processing - self.Anki = anki - self.Fields = fields - self.BaseNote = base_note - if enNote and light_processing and not fields: - self.Fields = {FIELDS.TITLE: enNote.FullTitle, FIELDS.CONTENT: enNote.Content, FIELDS.SEE_ALSO: u'', FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + enNote.Guid} - self.enNote = enNote - self.Changed = False - self.logged = False - if counts: - self.Counts = counts - else: - self.Counts.Updated = count_update - self.Counts.Current = count + 1 - self.Counts.Max = max_count - self.initialize_fields() - # self.Guid = get_evernote_guid_from_anki_fields(self.Fields) - self.NotebookGuid = notebookGuid - self.ModelName = None # MODELS.DEFAULT - # self.Title = EvernoteNoteTitle() - if not self.NotebookGuid and self.Anki: - self.NotebookGuid = self.Anki.get_notebook_guid_from_ankdb(self.Guid) - if not self.Guid and (self.light_processing or self.NotebookGuid): - log('Guid/Notebook Guid missing for: ' + self.FullTitle) - log(self.Guid) - log(self.NotebookGuid) - raise ValueError - self._deck_parent_ = self.Anki.deck if self.Anki else '' - assert tags is not None - self.Tags = tags - self.__cloze_count__ = 0 - self.process_note() - - def initialize_fields(self): - if self.BaseNote: - self.originalFields = get_dict_from_list(self.BaseNote.items()) - for field in FIELDS.LIST: - if not field in self.Fields: - self.Fields[field] = self.originalFields[field] if self.BaseNote else u'' - # self.Title = EvernoteNoteTitle(self.Fields) - - def deck(self): - deck = self._deck_parent_ - if TAGS.TOC in self.Tags or TAGS.AUTO_TOC in self.Tags: - deck += DECKS.TOC_SUFFIX - elif TAGS.OUTLINE in self.Tags and TAGS.OUTLINE_TESTABLE not in self.Tags: - deck += DECKS.OUTLINE_SUFFIX - elif not self._deck_parent_ or mw.col.conf.get(SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION, True): - deck = self.Anki.get_deck_name_from_evernote_notebook(self.NotebookGuid, self._deck_parent_) - if not deck: return None - if deck[:2] == '::': - deck = deck[2:] - return deck - - def evernote_cloze_regex(self, match): - matchText = match.group(2) - if matchText[0] == "#": - matchText = matchText[1:] - else: - self.__cloze_count__ += 1 - if self.__cloze_count__ == 0: - self.__cloze_count__ = 1 - return "%s{{c%d::%s}}%s" % (match.group(1), self.__cloze_count__, matchText, match.group(3)) - - def process_note_see_also(self): - if not FIELDS.SEE_ALSO in self.Fields or not FIELDS.EVERNOTE_GUID in self.Fields: - return - ankDB().execute("DELETE FROM %s WHERE source_evernote_guid = '%s' " % (TABLES.SEE_ALSO, self.Guid)) - link_num = 0 - for enLink in find_evernote_links(self.Fields[FIELDS.SEE_ALSO]): - link_num += 1 - title_text = enLink.FullTitle - is_toc = 1 if (title_text == "TOC") else 0 - is_outline = 1 if (title_text is "O" or title_text is "Outline") else 0 - ankDB().execute( - "INSERT INTO %s (source_evernote_guid, number, uid, shard, target_evernote_guid, html, title, from_toc, is_toc, is_outline) VALUES('%s', %d, %d, '%s', '%s', '%s', '%s', 0, %d, %d)" % ( - TABLES.SEE_ALSO, self.Guid, link_num, enLink.Uid, enLink.Shard, - enLink.Guid, enLink.HTML, title_text, is_toc, is_outline)) - - def process_note_content(self): - - def step_0_remove_evernote_css_attributes(): - ################################### Step 0: Correct weird Evernote formatting - self.Fields[FIELDS.CONTENT] = clean_evernote_css(self.Fields[FIELDS.CONTENT]) - - def step_1_modify_evernote_links(): - ################################### Step 1: Modify Evernote Links - # We need to modify Evernote's "Classic" Style Note Links due to an Anki bug with executing the evernote command with three forward slashes. - # For whatever reason, Anki cannot handle evernote links with three forward slashes, but *can* handle links with two forward slashes. - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote:///", "evernote://") - - # Modify Evernote's "New" Style Note links that point to the Evernote website. Normally these links open the note using Evernote's web client. - # The web client then opens the local Evernote executable. Modifying the links as below will skip this step and open the note directly using the local Evernote executable - self.Fields[FIELDS.CONTENT] = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', - r'evernote://view/\2/\1/\3/\3/', self.Fields[FIELDS.CONTENT]) - - if self.light_processing: - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote://", "evernote:///") - - def step_2_modify_image_links(): - ################################### Step 2: Modify Image Links - # Currently anknotes does not support rendering images embedded into an Evernote note. - # As a work around, this code will convert any link to an image on Dropbox, to an embedded <img> tag. - # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page - # Step 2.1: Modify HTML links to Dropbox images - dropbox_image_url_base_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))' - dropbox_image_url_html_link_regex = dropbox_image_url_base_regex + r'(?P<QueryString>(?:\?dl=(?:0|1))?)' - dropbox_image_src_subst = r'<a href="\g<URL>\g<QueryString>"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' - self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="%s"[^>]*>(?P<Title>.+?)</a>' % dropbox_image_url_html_link_regex, - dropbox_image_src_subst % "'\g<Title>'", self.Fields[FIELDS.CONTENT]) - - # Step 2.2: Modify Plain-text links to Dropbox images - try: - dropbox_image_url_regex = dropbox_image_url_base_regex + r'(?P<QueryString>\?dl=(?:0|1))(?P<Suffix>"?[^">])' - self.Fields[FIELDS.CONTENT] = re.sub(dropbox_image_url_regex, (dropbox_image_src_subst % "From Plain-Text Link") + r'\g<Suffix>', self.Fields[FIELDS.CONTENT]) - except: - log_error("\nERROR processing note, Step 2.2. Content: %s" % self.Fields[FIELDS.CONTENT]) - - # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link)" - self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="(?P<URL>.+?)"[^>]*>(?P<Title>\(Image Link.*\))</a>', - r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', - self.Fields[FIELDS.CONTENT]) - - def step_3_occlude_text(): - ################################### Step 3: Change white text to transparent - # I currently use white text in Evernote to display information that I want to be initially hidden, but visible when desired by selecting the white text. - # We will change the white text to a special "occluded" CSS class so it can be visible on the back of cards, and also so we can adjust the color for the front of cards when using night mode - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') - - ################################### Step 4: Automatically Occlude Text in <<Double Angle Brackets>> - self.Fields[FIELDS.CONTENT] = re.sub("(?s)(?P<Prefix><|<) ?(?P=Prefix) ?(?P<PrefixKeep>(?:</div>)?)(?P<OccludedText>.+?)(?P<Suffix>>|>) ?(?P=Suffix) ?", r'<<\g<PrefixKeep><div class="occluded">\g<OccludedText></div>>>', self.Fields[FIELDS.CONTENT]) - - def step_5_create_cloze_fields(): - ################################### Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. - self.Fields[FIELDS.CONTENT] = re.sub(r'([^{]){([^{].*?)}([^}])', self.evernote_cloze_regex, self.Fields[FIELDS.CONTENT]) - - def step_6_process_see_also_links(): - ################################### Step 6: Process "See Also: " Links - see_also_match = regex_see_also().search(self.Fields[FIELDS.CONTENT]) - if not see_also_match: - i_see_also = self.Fields[FIELDS.CONTENT].find("See Also") - if i_see_also > -1: - self.loggedSeeAlsoError = self.Guid - i_div = self.Fields[FIELDS.CONTENT].rfind("<div", 0, i_see_also) - if i_div is -1: i_div = i_see_also - log_error("No See Also Content Found, but phrase 'See Also' exists in " + self.Guid + ": " + self.FullTitle, crosspost_to_default=False) - log("No See Also Content Found, but phrase 'See Also' exists: \n" + self.Guid + ": " + self.FullTitle + " \n" + self.Fields[FIELDS.CONTENT][i_div:i_see_also+50] + '\n', 'SeeAlso\\MatchExpected') - log(self.Fields[FIELDS.CONTENT], 'SeeAlso\\MatchExpected\\'+self.FullTitle) - # raise ValueError - return - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group(0), see_also_match.group('Suffix')) - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<div><b><br/></b></div></en-note>', '</en-note>') - see_also = see_also_match.group('SeeAlso') - see_also_header = see_also_match.group('SeeAlsoHeader') - see_also_header_stripme = see_also_match.group('SeeAlsoHeaderStripMe') - if see_also_header_stripme: - see_also = see_also.replace(see_also_header, see_also_header.replace(see_also_header_stripme, '')) - if self.Fields[FIELDS.SEE_ALSO]: - self.Fields[FIELDS.SEE_ALSO] += "<br><br>\r\n" - self.Fields[FIELDS.SEE_ALSO] += see_also - if self.light_processing: - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group('Suffix'), self.Fields[FIELDS.SEE_ALSO] + see_also_match.group('Suffix')) - return - self.process_note_see_also() - - if not FIELDS.CONTENT in self.Fields: - return - self._unprocessed_content_ = self.Fields[FIELDS.CONTENT] - self._unprocessed_see_also_ = self.Fields[FIELDS.SEE_ALSO] - steps = [0, 1, 6] if self.light_processing else range(0,7) - if self.light_processing and not ANKI.NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING: - steps.remove(0) - if 0 in steps: step_0_remove_evernote_css_attributes() - step_1_modify_evernote_links() - if 2 in steps: - step_2_modify_image_links() - step_3_occlude_text() - step_5_create_cloze_fields() - step_6_process_see_also_links() - # TODO: Add support for extracting an 'Extra' field from the Evernote Note contents - ################################### Note Processing complete. - - def detect_note_model(self): - - # log('Title, self.model_name, tags, self.model_name', 'detectnotemodel') - # log(self.FullTitle, 'detectnotemodel') - # log(self.ModelName, 'detectnotemodel') - if FIELDS.CONTENT in self.Fields and "{{c1::" in self.Fields[FIELDS.CONTENT]: - self.ModelName = MODELS.CLOZE - if len(self.Tags) > 0: - reverse_override = (TAGS.TOC in self.Tags or TAGS.AUTO_TOC in self.Tags) - if TAGS.REVERSIBLE in self.Tags: - self.ModelName = MODELS.REVERSIBLE - self.Tags.remove(TAGS.REVERSIBLE) - elif TAGS.REVERSE_ONLY in self.Tags: - self.ModelName = MODELS.REVERSE_ONLY - self.Tags.remove(TAGS.REVERSE_ONLY) - if reverse_override: - self.ModelName = MODELS.DEFAULT - - # log(self.Tags, 'detectnotemodel') - # log(self.ModelName, 'detectnotemodel') - - def model_id(self): - if not self.ModelName: return None - return long(self.Anki.models().byName(self.ModelName)['id']) - - def process_note(self): - self.process_note_content() - if not self.light_processing: - self.detect_note_model() - - def update_note_model(self): - modelNameNew = self.ModelName - if not modelNameNew: return False - modelIdOld = self.note.mid - modelIdNew = self.model_id() - if modelIdOld == modelIdNew: - return False - mm = self.Anki.models() - modelOld = self.note.model() - modelNew = mm.get(modelIdNew) - modelNameOld = modelOld['name'] - fmap = get_self_referential_fmap() - cmap = {0: 0} - if modelNameOld == MODELS.REVERSE_ONLY and modelNameNew == MODELS.REVERSIBLE: - cmap[0] = 1 - elif modelNameOld == MODELS.REVERSIBLE: - if modelNameNew == MODELS.REVERSE_ONLY: - cmap = {0: None, 1: 0} - else: - cmap[1] = None - self.log_update("Changing model:\n From: '%s' \n To: '%s'" % (modelNameOld, modelNameNew)) - mm.change(modelOld, [self.note.id], modelNew, fmap, cmap) - self.Changed = True - return True - - def log_update(self, content=''): - if not self.logged: - count_updated_new = (self.Counts.Updated + 1 if content else 0) - count_str = '' - if self.Counts.Current > 0: - count_str = ' [' - if self.Counts.Current - count_updated_new > 0 and count_updated_new > 0: - count_str += '%3d/' % count_updated_new - count_str += '%-4d]/[' % self.Counts.Current - else: - count_str += '%4d/' % self.Counts.Current - count_str += '%-4d]' % self.Counts.Max - count_str += ' (%2d%%)' % (float(self.Counts.Current) / self.Counts.Max * 100) - log_title = '!' if content else '' - log_title += 'UPDATING NOTE%s: %-80s: %s' % (count_str, self.FullTitle, self.Guid) - log(log_title, 'AddUpdateNote', timestamp=(content is ''), - clear=((self.Counts.Current == 1 or self.Counts.Current == 100) and not self.logged)) - self.logged = True - if not content: return - content = obj2log_simple(content) - content = content.replace('\n', '\n ') - log(' > %s\n' % content, 'AddUpdateNote', timestamp=False) - - def update_note_tags(self): - if len(self.Tags) == 0: return False - self.Tags = get_tag_names_to_import(self.Tags) - if not self.BaseNote: - self.log_update("Error with unt") - self.log_update(self.Tags) - self.log_update(self.Fields) - self.log_update(self.BaseNote) - assert self.BaseNote - baseTags = sorted(self.BaseNote.tags, key=lambda s: s.lower()) - value = u','.join(self.Tags) - value_original = u','.join(baseTags) - if str(value) == str(value_original): - return False - self.log_update("Changing tags:\n From: '%s' \n To: '%s'" % (value_original, value)) - self.BaseNote.tags = self.Tags - self.Changed = True - return True - - def update_note_deck(self): - deckNameNew = self.deck() - if not deckNameNew: return False - deckIDNew = self.Anki.decks().id(deckNameNew) - deckIDOld = get_anki_deck_id_from_note_id(self.note.id) - if deckIDNew == deckIDOld: - return False - self.log_update( - "Changing deck:\n From: '%s' \n To: '%s'" % (self.Anki.decks().nameOrNone(deckIDOld), self.deck())) - # Not sure if this is necessary or Anki does it by itself: - ankDB().execute("UPDATE cards SET did = ? WHERE nid = ?", deckIDNew, self.note.id) - return True - - def update_note_fields(self): - fields_to_update = [FIELDS.TITLE, FIELDS.CONTENT, FIELDS.SEE_ALSO, FIELDS.UPDATE_SEQUENCE_NUM] - fld_content_ord = -1 - flag_changed = False - field_updates = [] - fields_updated = {} - for fld in self.note._model['flds']: - if FIELDS.EVERNOTE_GUID in fld.get('name'): - self.OriginalGuid = self.note.fields[fld.get('ord')].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') - for field_to_update in fields_to_update: - if field_to_update == fld.get('name') and field_to_update in self.Fields: - if field_to_update is FIELDS.CONTENT: - fld_content_ord = fld.get('ord') - try: - value = self.Fields[field_to_update] - value_original = self.note.fields[fld.get('ord')] - if isinstance(value, str): - value = unicode(value, 'utf-8') - if isinstance(value_original, str): - value_original = unicode(value_original, 'utf-8') - if not value == value_original: - flag_changed = True - self.note.fields[fld.get('ord')] = value - fields_updated[field_to_update] = value_original - if field_to_update is FIELDS.CONTENT or field_to_update is FIELDS.SEE_ALSO: - diff = generate_diff(value_original, value) - else: - diff = 'From: \n%s \n\n To: \n%s' % (value_original, value) - field_updates.append("Changing field #%d %s:\n%s" % (fld.get('ord'), field_to_update, diff)) - except: - self.log_update(field_updates) - log_error( - "ERROR: UPDATE_NOTE: Note '%s': %s: Unable to set self.note.fields for field '%s'. Ord: %s. Note fields count: %d" % ( - self.Guid, self.FullTitle, field_to_update, str(fld.get('ord')), - len(self.note.fields))) - raise - for update in field_updates: - self.log_update(update) - if flag_changed: self.Changed = True - return flag_changed - - def update_note(self): - self.note = self.BaseNote - self.logged = False - if not self.BaseNote: - self.log_update("Not updating Note: Could not find base note") - return -1 - self.Changed = False - self.update_note_tags() - self.update_note_fields() - i_see_also = self.Fields[FIELDS.CONTENT].find("See Also") - if i_see_also > -1: - i_div = self.Fields[FIELDS.CONTENT].rfind("<div", 0, i_see_also) - if i_div is -1: i_div = i_see_also - if not hasattr(self, 'loggedSeeAlsoError') or self.loggedSeeAlsoError != self.Guid: - log_error("No See Also Content Found, but phrase 'See Also' exists in " + self.Guid + ": " + self.FullTitle, crosspost_to_default=False) - log("No See Also Content Found, but phrase 'See Also' exists: \n" + self.Guid + ": " + self.FullTitle + " \n" + self.Fields[FIELDS.CONTENT][i_div:i_see_also+50] + '\n', 'SeeAlso\\MatchExpectedUpdate') - log(self.Fields[FIELDS.CONTENT], 'SeeAlso\\MatchExpectedUpdate\\'+self.FullTitle) - if not (self.Changed or self.update_note_deck()): - if self._log_update_if_unchanged_: - self.log_update("Not updating Note: The fields, tags, and deck are the same") - elif (self.Counts.Updated is 0 or self.Counts.Current / self.Counts.Updated > 9) and self.Counts.Current % 100 is 0: - self.log_update() - return 0 - if not self.Changed: - # i.e., the note deck has been changed but the tags and fields have not - self.Counts.Updated += 1 - return 1 - if not self.OriginalGuid: - flds = get_dict_from_list(self.BaseNote.items()) - self.OriginalGuid = get_evernote_guid_from_anki_fields(flds) - db_title = ankDB().scalar( - "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.OriginalGuid)) - new_guid = self.Guid - new_title = self.FullTitle - self.check_titles_equal(db_title, new_title, new_guid) - self.note.flush() - self.update_note_model() - self.Counts.Updated += 1 - return 1 - - - def check_titles_equal(self, old_title, new_title, new_guid, log_title='DB INFO UNEQUAL'): - do_log_title = False - if not isinstance(new_title, unicode): - try: new_title = unicode(new_title, 'utf-8') - except: do_log_title = True - if not isinstance(old_title, unicode): - try: old_title = unicode(old_title, 'utf-8') - except: do_log_title = True - guid_text = '' if self.OriginalGuid is None else ' ' + self.OriginalGuid + ('' if new_guid == self.OriginalGuid else ' vs %s' % new_guid) + ':' - if do_log_title or new_title != old_title or (self.OriginalGuid and new_guid != self.OriginalGuid): - log_str = ' %s: %s%s' % ('*' if do_log_title else ' ' + log_title, guid_text, ' ' + new_title + ' vs ' + old_title) - log_error(log_str, crosspost_to_default=False) - self.log_update(log_str) - return False - return True - - @property - def Title(self): - """:rtype : EvernoteNoteTitle.EvernoteNoteTitle """ - title = "" - if FIELDS.TITLE in self.Fields: - title = self.Fields[FIELDS.TITLE] - if self.BaseNote: - title = self.originalFields[FIELDS.TITLE] - return EvernoteNoteTitle(title) - - @property - def FullTitle(self): return self.Title.FullTitle - - def save_anki_fields_decoded(self, attempt, from_anp_fields=False, do_decode=None): - title = self.db_title if hasattr(self, 'db_title') else self.FullTitle - e_return=False - log_header='ANKI-->ANP-->' - if from_anp_fields: - log_header += 'CREATE ANKI FIELDS' - base_values = self.Fields.items() - else: - log_header += 'SAVE ANKI FIELDS (DECODED)' - base_values = enumerate(self.note.fields) - for key, value in base_values: - name = key if from_anp_fields else FIELDS.LIST[key - 1] if key > 0 else FIELDS.EVERNOTE_GUID - if isinstance(value, unicode) and not do_decode is True: action='ENCODING' - elif isinstance(value, str) and not do_decode is False: action='DECODING' - else: action='DOING NOTHING' - log('\t - %s for %s field %s' % (action, value.__class__.__name__, name), 'unicode', timestamp=False) - if action is not 'DOING NOTHING': - try: - new_value = value.encode('utf-8') if action=='ENCODED' else value.decode('utf-8') - if from_anp_fields: self.note[key] = new_value - else: self.note.fields[key] = new_value - except (UnicodeDecodeError, UnicodeEncodeError, UnicodeTranslateError, UnicodeError, Exception), e: - e_return = HandleUnicodeError(log_header, e, self.Guid, title, action, attempt, value, field=name) - if e_return is not 1: raise - if e_return is not False: log_blank('unicode') - return 1 - - def add_note_try(self, attempt=1): - title = self.db_title if hasattr(self, 'db_title') else self.FullTitle - col = self.Anki.collection() - log_header = 'ANKI-->ANP-->ADD NOTE FAILED' - action = 'DECODING?' - try: - col.addNote(self.note) - except (UnicodeDecodeError, UnicodeEncodeError, UnicodeTranslateError, UnicodeError, Exception), e: - e_return = HandleUnicodeError(log_header, e, self.Guid, title, action, attempt, self.note[FIELDS.TITLE]) - if e_return is not 1: raise - self.save_anki_fields_decoded(attempt+1) - return self.add_note_try(attempt+1) - return 1 - - def add_note(self): - self.create_note() - if self.note is None: return -1 - collection = self.Anki.collection() - db_title = ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.Guid)) - log(' %s: ADD: ' % self.Guid + ' ' + self.FullTitle, 'AddUpdateNote') - self.check_titles_equal(db_title, self.FullTitle, self.Guid, 'NEW NOTE TITLE UNEQUAL TO DB ENTRY') - if self.add_note_try() is not 1: return -1 - collection.autosave() - self.Anki.start_editing() - return self.note.id - - def create_note(self,attempt=1): - id_deck = self.Anki.decks().id(self.deck()) - if not self.ModelName: self.ModelName = MODELS.DEFAULT - model = self.Anki.models().byName(self.ModelName) - col = self.Anki.collection() - self.note = AnkiNote(col, model) - self.note.model()['did'] = id_deck - self.note.tags = self.Tags - title = self.db_title if hasattr(self, 'db_title') else self.FullTitle - self.save_anki_fields_decoded(attempt, True, True) \ No newline at end of file + Anki = None + """:type : anknotes.Anki.Anki """ + BaseNote = None + """:type : AnkiNote """ + enNote = None + """:type: EvernoteNotePrototype.EvernoteNotePrototype""" + Fields = {} + """:type : dict[str, str]""" + Tags = [] + """:type : list[str]""" + ModelName = None + """:type : str""" + # Guid = "" + # """:type : str""" + NotebookGuid = "" + """:type : str""" + __cloze_count__ = 0 + + class Counts: + Updated = 0 + Current = 0 + Max = 1 + + OriginalGuid = None + """:type : str""" + Changed = False + _unprocessed_content_ = "" + _unprocessed_see_also_ = "" + _log_update_if_unchanged_ = True + + @property + def Guid(self): + return get_evernote_guid_from_anki_fields(self.Fields) + + def __init__(self, anki=None, fields=None, tags=None, base_note=None, notebookGuid=None, count=-1, count_update=0, + max_count=1, counts=None, light_processing=False, enNote=None): + """ + Create Anki Note Prototype Class from fields or Base Anki Note + :param anki: Anki: Anknotes Main Class Instance + :type anki: anknotes.Anki.Anki + :param fields: Dict of Fields + :param tags: List of Tags + :type tags : list[str] + :param base_note: Base Anki Note if Updating an Existing Note + :type base_note : anki.notes.Note + :param enNote: Base Evernote Note Prototype from Anknotes DB, usually used just to process a note's contents + :type enNote : EvernoteNotePrototype.EvernoteNotePrototype + :param notebookGuid: + :param count: + :param count_update: + :param max_count: + :param counts: AnkiNotePrototype.Counts if being used to add/update multiple notes + :type counts : AnkiNotePrototype.Counts + :return: AnkiNotePrototype + """ + self.light_processing = light_processing + self.Anki = anki + self.Fields = fields + self.BaseNote = base_note + if enNote and light_processing and not fields: + self.Fields = {FIELDS.TITLE: enNote.FullTitle, FIELDS.CONTENT: enNote.Content, FIELDS.SEE_ALSO: u'', + FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + enNote.Guid} + self.enNote = enNote + self.Changed = False + self.logged = False + if counts: + self.Counts = counts + else: + self.Counts.Updated = count_update + self.Counts.Current = count + 1 + self.Counts.Max = max_count + self.initialize_fields() + # self.Guid = get_evernote_guid_from_anki_fields(self.Fields) + self.NotebookGuid = notebookGuid + self.ModelName = None # MODELS.DEFAULT + # self.Title = EvernoteNoteTitle() + if not self.NotebookGuid and self.Anki: + self.NotebookGuid = self.Anki.get_notebook_guid_from_ankdb(self.Guid) + if not self.Guid and (self.light_processing or self.NotebookGuid): + log('Guid/Notebook Guid missing for: ' + self.FullTitle) + log(self.Guid) + log(self.NotebookGuid) + raise ValueError + self._deck_parent_ = self.Anki.deck if self.Anki else '' + assert tags is not None + self.Tags = tags + self.__cloze_count__ = 0 + self.process_note() + + def initialize_fields(self): + if self.BaseNote: + self.originalFields = get_dict_from_list(self.BaseNote.items()) + for field in FIELDS.LIST: + if not field in self.Fields: + self.Fields[field] = self.originalFields[field] if self.BaseNote else u'' + # self.Title = EvernoteNoteTitle(self.Fields) + + def deck(self): + deck = self._deck_parent_ + if TAGS.TOC in self.Tags or TAGS.AUTO_TOC in self.Tags: + deck += DECKS.TOC_SUFFIX + elif TAGS.OUTLINE in self.Tags and TAGS.OUTLINE_TESTABLE not in self.Tags: + deck += DECKS.OUTLINE_SUFFIX + elif not self._deck_parent_ or mw.col.conf.get(SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION, True): + deck = self.Anki.get_deck_name_from_evernote_notebook(self.NotebookGuid, self._deck_parent_) + if not deck: return None + if deck[:2] == '::': + deck = deck[2:] + return deck + + def evernote_cloze_regex(self, match): + matchText = match.group(2) + if matchText[0] == "#": + matchText = matchText[1:] + else: + self.__cloze_count__ += 1 + if self.__cloze_count__ == 0: + self.__cloze_count__ = 1 + return "%s{{c%d::%s}}%s" % (match.group(1), self.__cloze_count__, matchText, match.group(3)) + + def process_note_see_also(self): + if not FIELDS.SEE_ALSO in self.Fields or not FIELDS.EVERNOTE_GUID in self.Fields: + return + ankDB().execute("DELETE FROM %s WHERE source_evernote_guid = '%s' " % (TABLES.SEE_ALSO, self.Guid)) + link_num = 0 + for enLink in find_evernote_links(self.Fields[FIELDS.SEE_ALSO]): + link_num += 1 + title_text = enLink.FullTitle + is_toc = 1 if (title_text == "TOC") else 0 + is_outline = 1 if (title_text is "O" or title_text is "Outline") else 0 + ankDB().execute( + "INSERT INTO %s (source_evernote_guid, number, uid, shard, target_evernote_guid, html, title, from_toc, is_toc, is_outline) VALUES('%s', %d, %d, '%s', '%s', '%s', '%s', 0, %d, %d)" % ( + TABLES.SEE_ALSO, self.Guid, link_num, enLink.Uid, enLink.Shard, + enLink.Guid, enLink.HTML, title_text, is_toc, is_outline)) + + def process_note_content(self): + + def step_0_remove_evernote_css_attributes(): + ################################### Step 0: Correct weird Evernote formatting + self.Fields[FIELDS.CONTENT] = clean_evernote_css(self.Fields[FIELDS.CONTENT]) + + def step_1_modify_evernote_links(): + ################################### Step 1: Modify Evernote Links + # We need to modify Evernote's "Classic" Style Note Links due to an Anki bug with executing the evernote command with three forward slashes. + # For whatever reason, Anki cannot handle evernote links with three forward slashes, but *can* handle links with two forward slashes. + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote:///", "evernote://") + + # Modify Evernote's "New" Style Note links that point to the Evernote website. Normally these links open the note using Evernote's web client. + # The web client then opens the local Evernote executable. Modifying the links as below will skip this step and open the note directly using the local Evernote executable + self.Fields[FIELDS.CONTENT] = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', + r'evernote://view/\2/\1/\3/\3/', self.Fields[FIELDS.CONTENT]) + + if self.light_processing: + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote://", "evernote:///") + + def step_2_modify_image_links(): + ################################### Step 2: Modify Image Links + # Currently anknotes does not support rendering images embedded into an Evernote note. + # As a work around, this code will convert any link to an image on Dropbox, to an embedded <img> tag. + # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page + # Step 2.1: Modify HTML links to Dropbox images + dropbox_image_url_base_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))' + dropbox_image_url_html_link_regex = dropbox_image_url_base_regex + r'(?P<QueryString>(?:\?dl=(?:0|1))?)' + dropbox_image_src_subst = r'<a href="\g<URL>\g<QueryString>"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' + self.Fields[FIELDS.CONTENT] = re.sub( + r'<a href="%s"[^>]*>(?P<Title>.+?)</a>' % dropbox_image_url_html_link_regex, + dropbox_image_src_subst % "'\g<Title>'", self.Fields[FIELDS.CONTENT]) + + # Step 2.2: Modify Plain-text links to Dropbox images + try: + dropbox_image_url_regex = dropbox_image_url_base_regex + r'(?P<QueryString>\?dl=(?:0|1))(?P<Suffix>"?[^">])' + self.Fields[FIELDS.CONTENT] = re.sub(dropbox_image_url_regex, + (dropbox_image_src_subst % "From Plain-Text Link") + r'\g<Suffix>', + self.Fields[FIELDS.CONTENT]) + except: + log_error("\nERROR processing note, Step 2.2. Content: %s" % self.Fields[FIELDS.CONTENT]) + + # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link)" + self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="(?P<URL>.+?)"[^>]*>(?P<Title>\(Image Link.*\))</a>', + r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', + self.Fields[FIELDS.CONTENT]) + + def step_3_occlude_text(): + ################################### Step 3: Change white text to transparent + # I currently use white text in Evernote to display information that I want to be initially hidden, but visible when desired by selecting the white text. + # We will change the white text to a special "occluded" CSS class so it can be visible on the back of cards, and also so we can adjust the color for the front of cards when using night mode + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace( + '<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') + + ################################### Step 4: Automatically Occlude Text in <<Double Angle Brackets>> + self.Fields[FIELDS.CONTENT] = re.sub( + "(?s)(?P<Prefix><|<) ?(?P=Prefix) ?(?P<PrefixKeep>(?:</div>)?)(?P<OccludedText>.+?)(?P<Suffix>>|>) ?(?P=Suffix) ?", + r'<<\g<PrefixKeep><div class="occluded">\g<OccludedText></div>>>', + self.Fields[FIELDS.CONTENT]) + + def step_5_create_cloze_fields(): + ################################### Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. + self.Fields[FIELDS.CONTENT] = re.sub(r'([^{]){([^{].*?)}([^}])', self.evernote_cloze_regex, + self.Fields[FIELDS.CONTENT]) + + def step_6_process_see_also_links(): + ################################### Step 6: Process "See Also: " Links + see_also_match = regex_see_also().search(self.Fields[FIELDS.CONTENT]) + if not see_also_match: + i_see_also = self.Fields[FIELDS.CONTENT].find("See Also") + if i_see_also > -1: + self.loggedSeeAlsoError = self.Guid + i_div = self.Fields[FIELDS.CONTENT].rfind("<div", 0, i_see_also) + if i_div is -1: i_div = i_see_also + log_error( + "No See Also Content Found, but phrase 'See Also' exists in " + self.Guid + ": " + self.FullTitle, + crosspost_to_default=False) + log( + "No See Also Content Found, but phrase 'See Also' exists: \n" + self.Guid + ": " + self.FullTitle + " \n" + + self.Fields[FIELDS.CONTENT][i_div:i_see_also + 50] + '\n', 'SeeAlso\\MatchExpected') + log(self.Fields[FIELDS.CONTENT], 'SeeAlso\\MatchExpected\\' + self.FullTitle) + # raise ValueError + return + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group(0), + see_also_match.group('Suffix')) + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<div><b><br/></b></div></en-note>', + '</en-note>') + see_also = see_also_match.group('SeeAlso') + see_also_header = see_also_match.group('SeeAlsoHeader') + see_also_header_stripme = see_also_match.group('SeeAlsoHeaderStripMe') + if see_also_header_stripme: + see_also = see_also.replace(see_also_header, see_also_header.replace(see_also_header_stripme, '')) + if self.Fields[FIELDS.SEE_ALSO]: + self.Fields[FIELDS.SEE_ALSO] += "<br><br>\r\n" + self.Fields[FIELDS.SEE_ALSO] += see_also + if self.light_processing: + self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace(see_also_match.group('Suffix'), + self.Fields[ + FIELDS.SEE_ALSO] + see_also_match.group( + 'Suffix')) + return + self.process_note_see_also() + + if not FIELDS.CONTENT in self.Fields: + return + self._unprocessed_content_ = self.Fields[FIELDS.CONTENT] + self._unprocessed_see_also_ = self.Fields[FIELDS.SEE_ALSO] + steps = [0, 1, 6] if self.light_processing else range(0, 7) + if self.light_processing and not ANKI.NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING: + steps.remove(0) + if 0 in steps: step_0_remove_evernote_css_attributes() + step_1_modify_evernote_links() + if 2 in steps: + step_2_modify_image_links() + step_3_occlude_text() + step_5_create_cloze_fields() + step_6_process_see_also_links() + + # TODO: Add support for extracting an 'Extra' field from the Evernote Note contents + ################################### Note Processing complete. + + def detect_note_model(self): + + # log('Title, self.model_name, tags, self.model_name', 'detectnotemodel') + # log(self.FullTitle, 'detectnotemodel') + # log(self.ModelName, 'detectnotemodel') + if FIELDS.CONTENT in self.Fields and "{{c1::" in self.Fields[FIELDS.CONTENT]: + self.ModelName = MODELS.CLOZE + if len(self.Tags) > 0: + reverse_override = (TAGS.TOC in self.Tags or TAGS.AUTO_TOC in self.Tags) + if TAGS.REVERSIBLE in self.Tags: + self.ModelName = MODELS.REVERSIBLE + self.Tags.remove(TAGS.REVERSIBLE) + elif TAGS.REVERSE_ONLY in self.Tags: + self.ModelName = MODELS.REVERSE_ONLY + self.Tags.remove(TAGS.REVERSE_ONLY) + if reverse_override: + self.ModelName = MODELS.DEFAULT + + # log(self.Tags, 'detectnotemodel') + # log(self.ModelName, 'detectnotemodel') + + def model_id(self): + if not self.ModelName: return None + return long(self.Anki.models().byName(self.ModelName)['id']) + + def process_note(self): + self.process_note_content() + if not self.light_processing: + self.detect_note_model() + + def update_note_model(self): + modelNameNew = self.ModelName + if not modelNameNew: return False + modelIdOld = self.note.mid + modelIdNew = self.model_id() + if modelIdOld == modelIdNew: + return False + mm = self.Anki.models() + modelOld = self.note.model() + modelNew = mm.get(modelIdNew) + modelNameOld = modelOld['name'] + fmap = get_self_referential_fmap() + cmap = {0: 0} + if modelNameOld == MODELS.REVERSE_ONLY and modelNameNew == MODELS.REVERSIBLE: + cmap[0] = 1 + elif modelNameOld == MODELS.REVERSIBLE: + if modelNameNew == MODELS.REVERSE_ONLY: + cmap = {0: None, 1: 0} + else: + cmap[1] = None + self.log_update("Changing model:\n From: '%s' \n To: '%s'" % (modelNameOld, modelNameNew)) + mm.change(modelOld, [self.note.id], modelNew, fmap, cmap) + self.Changed = True + return True + + def log_update(self, content=''): + if not self.logged: + count_updated_new = (self.Counts.Updated + 1 if content else 0) + count_str = '' + if self.Counts.Current > 0: + count_str = ' [' + if self.Counts.Current - count_updated_new > 0 and count_updated_new > 0: + count_str += '%3d/' % count_updated_new + count_str += '%-4d]/[' % self.Counts.Current + else: + count_str += '%4d/' % self.Counts.Current + count_str += '%-4d]' % self.Counts.Max + count_str += ' (%2d%%)' % (float(self.Counts.Current) / self.Counts.Max * 100) + log_title = '!' if content else '' + log_title += 'UPDATING NOTE%s: %-80s: %s' % (count_str, self.FullTitle, self.Guid) + log(log_title, 'AddUpdateNote', timestamp=(content is ''), + clear=((self.Counts.Current == 1 or self.Counts.Current == 100) and not self.logged)) + self.logged = True + if not content: return + content = obj2log_simple(content) + content = content.replace('\n', '\n ') + log(' > %s\n' % content, 'AddUpdateNote', timestamp=False) + + def update_note_tags(self): + if len(self.Tags) == 0: return False + self.Tags = get_tag_names_to_import(self.Tags) + if not self.BaseNote: + self.log_update("Error with unt") + self.log_update(self.Tags) + self.log_update(self.Fields) + self.log_update(self.BaseNote) + assert self.BaseNote + baseTags = sorted(self.BaseNote.tags, key=lambda s: s.lower()) + value = u','.join(self.Tags) + value_original = u','.join(baseTags) + if str(value) == str(value_original): + return False + self.log_update("Changing tags:\n From: '%s' \n To: '%s'" % (value_original, value)) + self.BaseNote.tags = self.Tags + self.Changed = True + return True + + def update_note_deck(self): + deckNameNew = self.deck() + if not deckNameNew: return False + deckIDNew = self.Anki.decks().id(deckNameNew) + deckIDOld = get_anki_deck_id_from_note_id(self.note.id) + if deckIDNew == deckIDOld: + return False + self.log_update( + "Changing deck:\n From: '%s' \n To: '%s'" % (self.Anki.decks().nameOrNone(deckIDOld), self.deck())) + # Not sure if this is necessary or Anki does it by itself: + ankDB().execute("UPDATE cards SET did = ? WHERE nid = ?", deckIDNew, self.note.id) + return True + + def update_note_fields(self): + fields_to_update = [FIELDS.TITLE, FIELDS.CONTENT, FIELDS.SEE_ALSO, FIELDS.UPDATE_SEQUENCE_NUM] + fld_content_ord = -1 + flag_changed = False + field_updates = [] + fields_updated = {} + for fld in self.note._model['flds']: + if FIELDS.EVERNOTE_GUID in fld.get('name'): + self.OriginalGuid = self.note.fields[fld.get('ord')].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') + for field_to_update in fields_to_update: + if field_to_update == fld.get('name') and field_to_update in self.Fields: + if field_to_update is FIELDS.CONTENT: + fld_content_ord = fld.get('ord') + try: + value = self.Fields[field_to_update] + value_original = self.note.fields[fld.get('ord')] + if isinstance(value, str): + value = unicode(value, 'utf-8') + if isinstance(value_original, str): + value_original = unicode(value_original, 'utf-8') + if not value == value_original: + flag_changed = True + self.note.fields[fld.get('ord')] = value + fields_updated[field_to_update] = value_original + if field_to_update is FIELDS.CONTENT or field_to_update is FIELDS.SEE_ALSO: + diff = generate_diff(value_original, value) + else: + diff = 'From: \n%s \n\n To: \n%s' % (value_original, value) + field_updates.append("Changing field #%d %s:\n%s" % (fld.get('ord'), field_to_update, diff)) + except: + self.log_update(field_updates) + log_error( + "ERROR: UPDATE_NOTE: Note '%s': %s: Unable to set self.note.fields for field '%s'. Ord: %s. Note fields count: %d" % ( + self.Guid, self.FullTitle, field_to_update, str(fld.get('ord')), + len(self.note.fields))) + raise + for update in field_updates: + self.log_update(update) + if flag_changed: self.Changed = True + return flag_changed + + def update_note(self): + self.note = self.BaseNote + self.logged = False + if not self.BaseNote: + self.log_update("Not updating Note: Could not find base note") + return -1 + self.Changed = False + self.update_note_tags() + self.update_note_fields() + i_see_also = self.Fields[FIELDS.CONTENT].find("See Also") + if i_see_also > -1: + i_div = self.Fields[FIELDS.CONTENT].rfind("<div", 0, i_see_also) + if i_div is -1: i_div = i_see_also + if not hasattr(self, 'loggedSeeAlsoError') or self.loggedSeeAlsoError != self.Guid: + log_error( + "No See Also Content Found, but phrase 'See Also' exists in " + self.Guid + ": " + self.FullTitle, + crosspost_to_default=False) + log( + "No See Also Content Found, but phrase 'See Also' exists: \n" + self.Guid + ": " + self.FullTitle + " \n" + + self.Fields[FIELDS.CONTENT][i_div:i_see_also + 50] + '\n', 'SeeAlso\\MatchExpectedUpdate') + log(self.Fields[FIELDS.CONTENT], 'SeeAlso\\MatchExpectedUpdate\\' + self.FullTitle) + if not (self.Changed or self.update_note_deck()): + if self._log_update_if_unchanged_: + self.log_update("Not updating Note: The fields, tags, and deck are the same") + elif ( + self.Counts.Updated is 0 or self.Counts.Current / self.Counts.Updated > 9) and self.Counts.Current % 100 is 0: + self.log_update() + return 0 + if not self.Changed: + # i.e., the note deck has been changed but the tags and fields have not + self.Counts.Updated += 1 + return 1 + if not self.OriginalGuid: + flds = get_dict_from_list(self.BaseNote.items()) + self.OriginalGuid = get_evernote_guid_from_anki_fields(flds) + db_title = ankDB().scalar( + "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.OriginalGuid)) + new_guid = self.Guid + new_title = self.FullTitle + self.check_titles_equal(db_title, new_title, new_guid) + self.note.flush() + self.update_note_model() + self.Counts.Updated += 1 + return 1 + + + def check_titles_equal(self, old_title, new_title, new_guid, log_title='DB INFO UNEQUAL'): + do_log_title = False + if not isinstance(new_title, unicode): + try: + new_title = unicode(new_title, 'utf-8') + except: + do_log_title = True + if not isinstance(old_title, unicode): + try: + old_title = unicode(old_title, 'utf-8') + except: + do_log_title = True + guid_text = '' if self.OriginalGuid is None else ' ' + self.OriginalGuid + ( + '' if new_guid == self.OriginalGuid else ' vs %s' % new_guid) + ':' + if do_log_title or new_title != old_title or (self.OriginalGuid and new_guid != self.OriginalGuid): + log_str = ' %s: %s%s' % ( + '*' if do_log_title else ' ' + log_title, guid_text, ' ' + new_title + ' vs ' + old_title) + log_error(log_str, crosspost_to_default=False) + self.log_update(log_str) + return False + return True + + @property + def Title(self): + """:rtype : EvernoteNoteTitle.EvernoteNoteTitle """ + title = "" + if FIELDS.TITLE in self.Fields: + title = self.Fields[FIELDS.TITLE] + if self.BaseNote: + title = self.originalFields[FIELDS.TITLE] + return EvernoteNoteTitle(title) + + @property + def FullTitle(self): + return self.Title.FullTitle + + def save_anki_fields_decoded(self, attempt, from_anp_fields=False, do_decode=None): + title = self.db_title if hasattr(self, 'db_title') else self.FullTitle + e_return = False + log_header = 'ANKI-->ANP-->' + if from_anp_fields: + log_header += 'CREATE ANKI FIELDS' + base_values = self.Fields.items() + else: + log_header += 'SAVE ANKI FIELDS (DECODED)' + base_values = enumerate(self.note.fields) + for key, value in base_values: + name = key if from_anp_fields else FIELDS.LIST[key - 1] if key > 0 else FIELDS.EVERNOTE_GUID + if isinstance(value, unicode) and not do_decode is True: + action = 'ENCODING' + elif isinstance(value, str) and not do_decode is False: + action = 'DECODING' + else: + action = 'DOING NOTHING' + log('\t - %s for %s field %s' % (action, value.__class__.__name__, name), 'unicode', timestamp=False) + if action is not 'DOING NOTHING': + try: + new_value = value.encode('utf-8') if action == 'ENCODED' else value.decode('utf-8') + if from_anp_fields: + self.note[key] = new_value + else: + self.note.fields[key] = new_value + except (UnicodeDecodeError, UnicodeEncodeError, UnicodeTranslateError, UnicodeError, Exception), e: + e_return = HandleUnicodeError(log_header, e, self.Guid, title, action, attempt, value, field=name) + if e_return is not 1: raise + if e_return is not False: log_blank('unicode') + return 1 + + def add_note_try(self, attempt=1): + title = self.db_title if hasattr(self, 'db_title') else self.FullTitle + col = self.Anki.collection() + log_header = 'ANKI-->ANP-->ADD NOTE FAILED' + action = 'DECODING?' + try: + col.addNote(self.note) + except (UnicodeDecodeError, UnicodeEncodeError, UnicodeTranslateError, UnicodeError, Exception), e: + e_return = HandleUnicodeError(log_header, e, self.Guid, title, action, attempt, self.note[FIELDS.TITLE]) + if e_return is not 1: raise + self.save_anki_fields_decoded(attempt + 1) + return self.add_note_try(attempt + 1) + return 1 + + def add_note(self): + self.create_note() + if self.note is None: return -1 + collection = self.Anki.collection() + db_title = ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.Guid)) + log(' %s: ADD: ' % self.Guid + ' ' + self.FullTitle, 'AddUpdateNote') + self.check_titles_equal(db_title, self.FullTitle, self.Guid, 'NEW NOTE TITLE UNEQUAL TO DB ENTRY') + if self.add_note_try() is not 1: return -1 + collection.autosave() + self.Anki.start_editing() + return self.note.id + + def create_note(self, attempt=1): + id_deck = self.Anki.decks().id(self.deck()) + if not self.ModelName: self.ModelName = MODELS.DEFAULT + model = self.Anki.models().byName(self.ModelName) + col = self.Anki.collection() + self.note = AnkiNote(col, model) + self.note.model()['did'] = id_deck + self.note.tags = self.Tags + title = self.db_title if hasattr(self, 'db_title') else self.FullTitle + self.save_anki_fields_decoded(attempt, True, True) \ No newline at end of file diff --git a/anknotes/Controller.py b/anknotes/Controller.py index 32eaa10..8bfe4f0 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -4,9 +4,9 @@ from datetime import datetime try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite ### Anknotes Shared Imports from anknotes.shared import * @@ -36,149 +36,166 @@ # load_time = datetime.now() # log("Loaded controller at " + load_time.isoformat(), 'import') class Controller: - evernoteImporter = None - """:type : EvernoteImporter""" + evernoteImporter = None + """:type : EvernoteImporter""" - def __init__(self): - self.forceAutoPage = False - self.auto_page_callback = None - self.anki = Anki() - self.anki.deck = mw.col.conf.get(SETTINGS.ANKI.DECKS.BASE, SETTINGS.ANKI.DECKS.BASE_DEFAULT_VALUE) - self.anki.setup_ancillary_files() - ankDB().Init() - self.anki.add_evernote_models() - self.evernote = Evernote() + def __init__(self): + self.forceAutoPage = False + self.auto_page_callback = None + self.anki = Anki() + self.anki.deck = mw.col.conf.get(SETTINGS.ANKI.DECKS.BASE, SETTINGS.ANKI.DECKS.BASE_DEFAULT_VALUE) + self.anki.setup_ancillary_files() + ankDB().Init() + self.anki.add_evernote_models() + self.evernote = Evernote() - def test_anki(self, title, evernote_guid, filename=""): - if not filename: filename = title - fields = { - FIELDS.TITLE: title, - FIELDS.CONTENT: file( - os.path.join(FOLDERS.LOGS, filename.replace('.enex', '') + ".enex"), - 'r').read(), FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid - } - tags = ['NoTags', 'NoTagsToRemove'] - return AnkiNotePrototype(self.anki, fields, tags) + def test_anki(self, title, evernote_guid, filename=""): + if not filename: filename = title + fields = { + FIELDS.TITLE: title, + FIELDS.CONTENT: file( + os.path.join(FOLDERS.LOGS, filename.replace('.enex', '') + ".enex"), + 'r').read(), FIELDS.EVERNOTE_GUID: FIELDS.EVERNOTE_GUID_PREFIX + evernote_guid + } + tags = ['NoTags', 'NoTagsToRemove'] + return AnkiNotePrototype(self.anki, fields, tags) - def process_unadded_see_also_notes(self): - update_regex() - anki_note_ids = self.anki.get_anknotes_note_ids_with_unadded_see_also() - self.evernote.getNoteCount = 0 - self.anki.process_see_also_content(anki_note_ids) + def process_unadded_see_also_notes(self): + update_regex() + anki_note_ids = self.anki.get_anknotes_note_ids_with_unadded_see_also() + self.evernote.getNoteCount = 0 + self.anki.process_see_also_content(anki_note_ids) - def upload_validated_notes(self, automated=False): - dbRows = ankDB().all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.NOTE_VALIDATION_QUEUE) - did_break=True - notes_created, notes_updated, queries1, queries2 = ([] for i in range(4)) - """ - :type: (list[EvernoteNote], list[EvernoteNote], list[str], list[str]) - """ - noteFetcher = EvernoteNoteFetcher() - tmr = stopwatch.Timer(len(dbRows), 25, "Upload of Validated Evernote Notes", automated=automated, enabled=EVERNOTE.UPLOAD.ENABLED, max_allowed=EVERNOTE.UPLOAD.MAX, display_initial_info=True) - if tmr.actionInitializationFailed: return tmr.status, 0, 0 - for dbRow in dbRows: - entry = EvernoteValidationEntry(dbRow) - evernote_guid, rootTitle, contents, tagNames, notebookGuid = entry.items() - tagNames = tagNames.split(',') - if not tmr.checkLimits(): break - whole_note = tmr.autoStep(self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid, validated=True), rootTitle, evernote_guid) - if tmr.report_result == False: raise ValueError - if tmr.status.IsDelayableError: break - if not tmr.status.IsSuccess: continue - if not whole_note.tagNames: whole_note.tagNames = tagNames - noteFetcher.addNoteFromServerToDB(whole_note, tagNames) - note = EvernoteNotePrototype(whole_note=whole_note) - assert whole_note.tagNames - assert note.Tags - if evernote_guid: - notes_updated.append(note) - queries1.append([evernote_guid]) - else: - notes_created.append(note) - queries2.append([rootTitle, contents]) - else: tmr.reportNoBreak() - tmr.Report(self.anki.add_evernote_notes(notes_created) if tmr.counts.created else 0, self.anki.update_evernote_notes(notes_updated) if tmr.counts.updated else 0) - if tmr.counts.created.completed.subcount: ankDB().executemany("DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries2) - if tmr.counts.updated.completed.subcount: ankDB().executemany("DELETE FROM %s WHERE guid = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries1) - if tmr.is_success: ankDB().commit() - if tmr.should_retry: mw.progress.timer((30 if tmr.status.IsDelayableError else EVERNOTE.UPLOAD.RESTART_INTERVAL) * 1000, lambda: self.upload_validated_notes(True), False) - return tmr.status, tmr.count, 0 + def upload_validated_notes(self, automated=False): + dbRows = ankDB().all("SELECT * FROM %s WHERE validation_status = 1 " % TABLES.NOTE_VALIDATION_QUEUE) + did_break = True + notes_created, notes_updated, queries1, queries2 = ([] for i in range(4)) + """ + :type: (list[EvernoteNote], list[EvernoteNote], list[str], list[str]) + """ + noteFetcher = EvernoteNoteFetcher() + tmr = stopwatch.Timer(len(dbRows), 25, "Upload of Validated Evernote Notes", automated=automated, + enabled=EVERNOTE.UPLOAD.ENABLED, max_allowed=EVERNOTE.UPLOAD.MAX, + display_initial_info=True) + if tmr.actionInitializationFailed: return tmr.status, 0, 0 + for dbRow in dbRows: + entry = EvernoteValidationEntry(dbRow) + evernote_guid, rootTitle, contents, tagNames, notebookGuid = entry.items() + tagNames = tagNames.split(',') + if not tmr.checkLimits(): break + whole_note = tmr.autoStep( + self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid, validated=True), + rootTitle, evernote_guid) + if tmr.report_result == False: raise ValueError + if tmr.status.IsDelayableError: break + if not tmr.status.IsSuccess: continue + if not whole_note.tagNames: whole_note.tagNames = tagNames + noteFetcher.addNoteFromServerToDB(whole_note, tagNames) + note = EvernoteNotePrototype(whole_note=whole_note) + assert whole_note.tagNames + assert note.Tags + if evernote_guid: + notes_updated.append(note) + queries1.append([evernote_guid]) + else: + notes_created.append(note) + queries2.append([rootTitle, contents]) + else: + tmr.reportNoBreak() + tmr.Report(self.anki.add_evernote_notes(notes_created) if tmr.counts.created else 0, + self.anki.update_evernote_notes(notes_updated) if tmr.counts.updated else 0) + if tmr.counts.created.completed.subcount: ankDB().executemany( + "DELETE FROM %s WHERE title = ? and contents = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries2) + if tmr.counts.updated.completed.subcount: ankDB().executemany( + "DELETE FROM %s WHERE guid = ? " % TABLES.NOTE_VALIDATION_QUEUE, queries1) + if tmr.is_success: ankDB().commit() + if tmr.should_retry: mw.progress.timer( + (30 if tmr.status.IsDelayableError else EVERNOTE.UPLOAD.RESTART_INTERVAL) * 1000, + lambda: self.upload_validated_notes(True), False) + return tmr.status, tmr.count, 0 - def create_auto_toc(self): - def check_old_values(): - old_values = ankDB().first( - "SELECT guid, content FROM %s WHERE UPPER(title) = ? AND tagNames LIKE '%%,' || ? || ',%%'" % TABLES.EVERNOTE.NOTES, - rootTitle.upper(), TAGS.AUTO_TOC) - if not old_values: - log(rootTitle, 'AutoTOC-Create\\Add') - return None, contents - evernote_guid, old_content = old_values - # log(['old contents exist', old_values is None, old_values, evernote_guid, old_content]) - noteBodyUnencoded = self.evernote.makeNoteBody(contents, encode=False) - if type(old_content) != type(noteBodyUnencoded): - log([rootTitle, type(old_content), type(noteBodyUnencoded)], 'AutoTOC-Create\\Update\\Diffs\\_') - raise UnicodeWarning - old_content = old_content.replace('guid-pending', evernote_guid).replace("'", '"') - noteBodyUnencoded = noteBodyUnencoded.replace('guid-pending', evernote_guid).replace("'", '"') - if old_content == noteBodyUnencoded: - log(rootTitle, 'AutoTOC-Create\\Skipped') - tmr.reportSkipped() - return None, None - log(noteBodyUnencoded, 'AutoTOC-Create\\Update\\New\\'+rootTitle, clear=True) - log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create\\Update\\Diffs\\'+rootTitle, clear=True) - return evernote_guid, contents.replace('/guid-pending/', '/%s/' % evernote_guid).replace('/guid-pending/', '/%s/' % evernote_guid) + def create_auto_toc(self): + def check_old_values(): + old_values = ankDB().first( + "SELECT guid, content FROM %s WHERE UPPER(title) = ? AND tagNames LIKE '%%,' || ? || ',%%'" % TABLES.EVERNOTE.NOTES, + rootTitle.upper(), TAGS.AUTO_TOC) + if not old_values: + log(rootTitle, 'AutoTOC-Create\\Add') + return None, contents + evernote_guid, old_content = old_values + # log(['old contents exist', old_values is None, old_values, evernote_guid, old_content]) + noteBodyUnencoded = self.evernote.makeNoteBody(contents, encode=False) + if type(old_content) != type(noteBodyUnencoded): + log([rootTitle, type(old_content), type(noteBodyUnencoded)], 'AutoTOC-Create\\Update\\Diffs\\_') + raise UnicodeWarning + old_content = old_content.replace('guid-pending', evernote_guid).replace("'", '"') + noteBodyUnencoded = noteBodyUnencoded.replace('guid-pending', evernote_guid).replace("'", '"') + if old_content == noteBodyUnencoded: + log(rootTitle, 'AutoTOC-Create\\Skipped') + tmr.reportSkipped() + return None, None + log(noteBodyUnencoded, 'AutoTOC-Create\\Update\\New\\' + rootTitle, clear=True) + log(generate_diff(old_content, noteBodyUnencoded), 'AutoTOC-Create\\Update\\Diffs\\' + rootTitle, + clear=True) + return evernote_guid, contents.replace('/guid-pending/', '/%s/' % evernote_guid).replace('/guid-pending/', + '/%s/' % evernote_guid) - update_regex() - NotesDB = EvernoteNotes() - NotesDB.baseQuery = ANKNOTES.HIERARCHY.ROOT_TITLES_BASE_QUERY - dbRows = NotesDB.populateAllNonCustomRootNotes() - notes_created, notes_updated = [], [] - """ - :type: (list[EvernoteNote], list[EvernoteNote]) - """ - info = stopwatch.ActionInfo('Creation of Table of Content Note(s)', row_source='Root Title(s)', enabled=EVERNOTE.UPLOAD.ENABLED) - tmr = stopwatch.Timer(len(dbRows), 25, info, max_allowed=EVERNOTE.UPLOAD.MAX) - tmr.label = 'create-auto_toc' - if tmr.actionInitializationFailed: return tmr.tmr.status, 0, 0 - for dbRow in dbRows: - evernote_guid = None - rootTitle, contents, tagNames, notebookGuid = dbRow.items() - tagNames = (set(tagNames[1:-1].split(',')) | {TAGS.TOC, TAGS.AUTO_TOC} | ({"#Sandbox"} if EVERNOTE.API.IS_SANDBOXED else set())) - {TAGS.REVERSIBLE, TAGS.REVERSE_ONLY} - rootTitle = generateTOCTitle(rootTitle) - evernote_guid, contents = check_old_values() - if contents is None: continue - if not tmr.checkLimits(): break - whole_note = tmr.autoStep(self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid), rootTitle, evernote_guid) - if tmr.report_result == False: raise ValueError - if tmr.status.IsDelayableError: break - if not tmr.status.IsSuccess: continue - (notes_updated if evernote_guid else notes_created).append(EvernoteNotePrototype(whole_note=whole_note)) - tmr.Report(self.anki.add_evernote_notes(notes_created) if tmr.counts.created.completed else 0, self.anki.update_evernote_notes(notes_updated) if tmr.counts.updated.completed else 0) - if tmr.counts.queued: ankDB().commit() - return tmr.status, tmr.count, tmr.counts.skipped.val + update_regex() + NotesDB = EvernoteNotes() + NotesDB.baseQuery = ANKNOTES.HIERARCHY.ROOT_TITLES_BASE_QUERY + dbRows = NotesDB.populateAllNonCustomRootNotes() + notes_created, notes_updated = [], [] + """ + :type: (list[EvernoteNote], list[EvernoteNote]) + """ + info = stopwatch.ActionInfo('Creation of Table of Content Note(s)', row_source='Root Title(s)', + enabled=EVERNOTE.UPLOAD.ENABLED) + tmr = stopwatch.Timer(len(dbRows), 25, info, max_allowed=EVERNOTE.UPLOAD.MAX) + tmr.label = 'create-auto_toc' + if tmr.actionInitializationFailed: return tmr.tmr.status, 0, 0 + for dbRow in dbRows: + evernote_guid = None + rootTitle, contents, tagNames, notebookGuid = dbRow.items() + tagNames = (set(tagNames[1:-1].split(',')) | {TAGS.TOC, TAGS.AUTO_TOC} | ( + {"#Sandbox"} if EVERNOTE.API.IS_SANDBOXED else set())) - {TAGS.REVERSIBLE, TAGS.REVERSE_ONLY} + rootTitle = generateTOCTitle(rootTitle) + evernote_guid, contents = check_old_values() + if contents is None: continue + if not tmr.checkLimits(): break + whole_note = tmr.autoStep( + self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid), rootTitle, + evernote_guid) + if tmr.report_result == False: raise ValueError + if tmr.status.IsDelayableError: break + if not tmr.status.IsSuccess: continue + (notes_updated if evernote_guid else notes_created).append(EvernoteNotePrototype(whole_note=whole_note)) + tmr.Report(self.anki.add_evernote_notes(notes_created) if tmr.counts.created.completed else 0, + self.anki.update_evernote_notes(notes_updated) if tmr.counts.updated.completed else 0) + if tmr.counts.queued: ankDB().commit() + return tmr.status, tmr.count, tmr.counts.skipped.val - def update_ancillary_data(self): - self.evernote.update_ancillary_data() + def update_ancillary_data(self): + self.evernote.update_ancillary_data() - def proceed(self, auto_paging=False): - if not self.evernoteImporter: - self.evernoteImporter = EvernoteImporter() - self.evernoteImporter.anki = self.anki - self.evernoteImporter.evernote = self.evernote - self.evernoteImporter.forceAutoPage = self.forceAutoPage - self.evernoteImporter.auto_page_callback = self.auto_page_callback - if not hasattr(self, 'currentPage'): - self.currentPage = 1 - self.evernoteImporter.currentPage = self.currentPage - if hasattr(self, 'ManualGUIDs'): - self.evernoteImporter.ManualGUIDs = self.ManualGUIDs - self.evernoteImporter.proceed(auto_paging) + def proceed(self, auto_paging=False): + if not self.evernoteImporter: + self.evernoteImporter = EvernoteImporter() + self.evernoteImporter.anki = self.anki + self.evernoteImporter.evernote = self.evernote + self.evernoteImporter.forceAutoPage = self.forceAutoPage + self.evernoteImporter.auto_page_callback = self.auto_page_callback + if not hasattr(self, 'currentPage'): + self.currentPage = 1 + self.evernoteImporter.currentPage = self.currentPage + if hasattr(self, 'ManualGUIDs'): + self.evernoteImporter.ManualGUIDs = self.ManualGUIDs + self.evernoteImporter.proceed(auto_paging) - def resync_with_local_db(self): - evernote_guids = get_all_local_db_guids() - results = self.evernote.create_evernote_notes(evernote_guids, use_local_db_only=True) - """:type: EvernoteNoteFetcherResults""" - show_report('Resync with Local DB: Starting Anki Update of %d Note(s)' % len(evernote_guids)) - number = self.anki.update_evernote_notes(results.Notes, log_update_if_unchanged=False) - tooltip = '%d Evernote Notes Created<BR>%d Anki Notes Successfully Updated' % (results.Local, number) - show_report('Resync with Local DB Complete') + def resync_with_local_db(self): + evernote_guids = get_all_local_db_guids() + results = self.evernote.create_evernote_notes(evernote_guids, use_local_db_only=True) + """:type: EvernoteNoteFetcherResults""" + show_report('Resync with Local DB: Starting Anki Update of %d Note(s)' % len(evernote_guids)) + number = self.anki.update_evernote_notes(results.Notes, log_update_if_unchanged=False) + tooltip = '%d Evernote Notes Created<BR>%d Anki Notes Successfully Updated' % (results.Local, number) + show_report('Resync with Local DB Complete') diff --git a/anknotes/EvernoteImporter.py b/anknotes/EvernoteImporter.py index 2216441..e404fa7 100644 --- a/anknotes/EvernoteImporter.py +++ b/anknotes/EvernoteImporter.py @@ -3,9 +3,9 @@ import socket try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite ### Anknotes Shared Imports from anknotes.shared import * @@ -20,8 +20,10 @@ from anknotes.EvernoteNotes import EvernoteNotes from anknotes.EvernoteNotePrototype import EvernoteNotePrototype -try: from anknotes import settings -except: pass +try: + from anknotes import settings +except: + pass ### Evernote Imports from anknotes.evernote.edam.notestore.ttypes import NoteFilter, NotesMetadataResultSpec, NoteMetadata, NotesMetadataList @@ -29,273 +31,290 @@ from anknotes.evernote.edam.error.ttypes import EDAMSystemException ### Anki Imports -try: from aqt import mw -except: pass +try: + from aqt import mw +except: + pass class EvernoteImporter: - forceAutoPage = False - auto_page_callback = None - """:type : lambda""" - anki = None - """:type : Anki""" - evernote = None - """:type : Evernote""" - updateExistingNotes = UpdateExistingNotes.UpdateNotesInPlace - ManualGUIDs = None - @property - def ManualMetadataMode(self): - return (self.ManualGUIDs is not None and len(self.ManualGUIDs) > 0) + forceAutoPage = False + auto_page_callback = None + """:type : lambda""" + anki = None + """:type : Anki""" + evernote = None + """:type : Evernote""" + updateExistingNotes = UpdateExistingNotes.UpdateNotesInPlace + ManualGUIDs = None + + @property + def ManualMetadataMode(self): + return (self.ManualGUIDs is not None and len(self.ManualGUIDs) > 0) + + def __init(self): + self.updateExistingNotes = mw.col.conf.get(SETTINGS.ANKI.UPDATE_EXISTING_NOTES, + UpdateExistingNotes.UpdateNotesInPlace) + self.ManualGUIDs = None + + def override_evernote_metadata(self): + guids = self.ManualGUIDs + self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) + self.MetadataProgress.Total = len(guids) + self.MetadataProgress.Current = min(self.MetadataProgress.Total - self.MetadataProgress.Offset, + EVERNOTE.IMPORT.QUERY_LIMIT) + result = NotesMetadataList() + result.totalNotes = len(guids) + result.updateCount = -1 + result.startIndex = self.MetadataProgress.Offset + result.notes = [] + """:type : list[NoteMetadata]""" + for i in range(self.MetadataProgress.Offset, self.MetadataProgress.Completed): + result.notes.append(NoteMetadata(guids[i])) + self.MetadataProgress.loadResults(result) + self.evernote.metadata = self.MetadataProgress.NotesMetadata + return True - def __init(self): - self.updateExistingNotes = mw.col.conf.get(SETTINGS.ANKI.UPDATE_EXISTING_NOTES, - UpdateExistingNotes.UpdateNotesInPlace) - self.ManualGUIDs = None + def get_evernote_metadata(self): + """ + :returns: Metadata Progress Instance + :rtype : EvernoteMetadataProgress) + """ + query = settings.generate_evernote_query() + evernote_filter = NoteFilter(words=query, ascending=True, order=NoteSortOrder.UPDATED) + self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) + spec = NotesMetadataResultSpec(includeTitle=False, includeUpdated=False, includeUpdateSequenceNum=True, + includeTagGuids=True, includeNotebookGuid=True) + notestore_status = self.evernote.initialize_note_store() + if not notestore_status.IsSuccess: + self.MetadataProgress.Status = notestore_status + return False # notestore_status + api_action_str = u'trying to search for note metadata' + log_api("findNotesMetadata", "[Offset: %3d]: Query: '%s'" % (self.MetadataProgress.Offset, query)) + try: + result = self.evernote.noteStore.findNotesMetadata(self.evernote.token, evernote_filter, + self.MetadataProgress.Offset, + EVERNOTE.IMPORT.METADATA_RESULTS_LIMIT, spec) + """ + :type: NotesMetadataList + """ + except EDAMSystemException as e: + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + self.MetadataProgress.Status = EvernoteAPIStatus.RateLimitError + return False + except socket.error, v: + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + self.MetadataProgress.Status = EvernoteAPIStatus.SocketError + return False + self.MetadataProgress.loadResults(result) + self.evernote.metadata = self.MetadataProgress.NotesMetadata + log(self.MetadataProgress.Summary, line_padding_header="- Metadata Results: ", + line_padding=ANKNOTES.FORMATTING.LINE_PADDING_HEADER, timestamp=False) + return True - def override_evernote_metadata(self): - guids = self.ManualGUIDs - self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) - self.MetadataProgress.Total = len(guids) - self.MetadataProgress.Current = min(self.MetadataProgress.Total - self.MetadataProgress.Offset, EVERNOTE.IMPORT.QUERY_LIMIT) - result = NotesMetadataList() - result.totalNotes = len(guids) - result.updateCount = -1 - result.startIndex = self.MetadataProgress.Offset - result.notes = [] - """:type : list[NoteMetadata]""" - for i in range(self.MetadataProgress.Offset, self.MetadataProgress.Completed): - result.notes.append(NoteMetadata(guids[i])) - self.MetadataProgress.loadResults(result) - self.evernote.metadata = self.MetadataProgress.NotesMetadata - return True + def update_in_anki(self, evernote_guids): + """ + :rtype : EvernoteNoteFetcherResults + """ + Results = self.evernote.create_evernote_notes(evernote_guids) + if self.ManualMetadataMode: + self.evernote.check_notebooks_up_to_date() + self.anki.notebook_data = self.evernote.notebook_data + Results.Imported = self.anki.update_evernote_notes(Results.Notes) + return Results - def get_evernote_metadata(self): - """ - :returns: Metadata Progress Instance - :rtype : EvernoteMetadataProgress) - """ - query = settings.generate_evernote_query() - evernote_filter = NoteFilter(words=query, ascending=True, order=NoteSortOrder.UPDATED) - self.MetadataProgress = EvernoteMetadataProgress(self.currentPage) - spec = NotesMetadataResultSpec(includeTitle=False, includeUpdated=False, includeUpdateSequenceNum=True, - includeTagGuids=True, includeNotebookGuid=True) - notestore_status = self.initialize_note_store() - if not notestore_status.IsSuccess: - self.MetadataProgress.Status = notestore_status - return False # notestore_status - api_action_str = u'trying to search for note metadata' - log_api("findNotesMetadata", "[Offset: %3d]: Query: '%s'" % (self.MetadataProgress.Offset, query)) - try: - result = self.evernote.noteStore.findNotesMetadata(self.evernote.token, evernote_filter, - self.MetadataProgress.Offset, - EVERNOTE.IMPORT.METADATA_RESULTS_LIMIT, spec) - """ - :type: NotesMetadataList - """ - except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - self.MetadataProgress.Status = EvernoteAPIStatus.RateLimitError - return False - except socket.error, v: - if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - self.MetadataProgress.Status = EvernoteAPIStatus.SocketError - return False - self.MetadataProgress.loadResults(result) - self.evernote.metadata = self.MetadataProgress.NotesMetadata - log(self.MetadataProgress.Summary, line_padding_header="- Metadata Results: ", line_padding=ANKNOTES.FORMATTING.LINE_PADDING_HEADER, timestamp=False) - return True + def import_into_anki(self, evernote_guids): + """ + :rtype : EvernoteNoteFetcherResults + """ + Results = self.evernote.create_evernote_notes(evernote_guids) + if self.ManualMetadataMode: + self.evernote.check_notebooks_up_to_date() + self.anki.notebook_data = self.evernote.notebook_data + Results.Imported = self.anki.add_evernote_notes(Results.Notes) + return Results - def update_in_anki(self, evernote_guids): - """ - :rtype : EvernoteNoteFetcherResults - """ - Results = self.evernote.create_evernote_notes(evernote_guids) - if self.ManualMetadataMode: - self.evernote.check_notebooks_up_to_date() - self.anki.notebook_data = self.evernote.notebook_data - Results.Imported = self.anki.update_evernote_notes(Results.Notes) - return Results + def check_note_sync_status(self, evernote_guids): + """ + Check for already existing, up-to-date, local db entries by Evernote GUID + :param evernote_guids: List of GUIDs + :return: List of Already Existing Evernote GUIDs + :rtype: list[str] + """ + notes_already_up_to_date = [] + if not evernote_guids: + return notes_already_up_to_date + for evernote_guid in evernote_guids: + db_usn = ankDB().scalar("SELECT updateSequenceNum FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, + evernote_guid) + if not self.evernote.metadata[evernote_guid].updateSequenceNum: + server_usn = 'N/A' + else: + server_usn = self.evernote.metadata[evernote_guid].updateSequenceNum + if evernote_guid in self.anki.usns: + current_usn = self.anki.usns[evernote_guid] + if current_usn == str(server_usn): + log_info = None # 'ANKI NOTE UP-TO-DATE' + notes_already_up_to_date.append(evernote_guid) + elif str(db_usn) == str(server_usn): + log_info = 'DATABASE ENTRY UP-TO-DATE' + else: + log_info = 'NO COPIES UP-TO-DATE' + else: + current_usn = 'N/A' + log_info = 'NO ANKI USN EXISTS' + if log_info: + if not self.evernote.metadata[evernote_guid].updateSequenceNum: + log_info += ' (Unable to find Evernote Metadata) ' + log(" > USN check for note '%s': %s: db/current/server = %s,%s,%s" % ( + evernote_guid, log_info, str(db_usn), str(current_usn), str(server_usn)), 'usn') + return notes_already_up_to_date - def import_into_anki(self, evernote_guids): - """ - :rtype : EvernoteNoteFetcherResults - """ - Results = self.evernote.create_evernote_notes(evernote_guids) - if self.ManualMetadataMode: - self.evernote.check_notebooks_up_to_date() - self.anki.notebook_data = self.evernote.notebook_data - Results.Imported = self.anki.add_evernote_notes(Results.Notes) - return Results + def proceed(self, auto_paging=False): + self.proceed_start(auto_paging) + self.proceed_find_metadata(auto_paging) + self.proceed_import_notes() + self.proceed_autopage() - def check_note_sync_status(self, evernote_guids): - """ - Check for already existing, up-to-date, local db entries by Evernote GUID - :param evernote_guids: List of GUIDs - :return: List of Already Existing Evernote GUIDs - :rtype: list[str] - """ - notes_already_up_to_date = [] - for evernote_guid in evernote_guids: - db_usn = ankDB().scalar("SELECT updateSequenceNum FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, - evernote_guid) - if not self.evernote.metadata[evernote_guid].updateSequenceNum: - server_usn = 'N/A' - else: - server_usn = self.evernote.metadata[evernote_guid].updateSequenceNum - if evernote_guid in self.anki.usns: - current_usn = self.anki.usns[evernote_guid] - if current_usn == str(server_usn): - log_info = None # 'ANKI NOTE UP-TO-DATE' - notes_already_up_to_date.append(evernote_guid) - elif str(db_usn) == str(server_usn): - log_info = 'DATABASE ENTRY UP-TO-DATE' - else: - log_info = 'NO COPIES UP-TO-DATE' - else: - current_usn = 'N/A' - log_info = 'NO ANKI USN EXISTS' - if log_info: - if not self.evernote.metadata[evernote_guid].updateSequenceNum: - log_info += ' (Unable to find Evernote Metadata) ' - log(" > USN check for note '%s': %s: db/current/server = %s,%s,%s" % ( - evernote_guid, log_info, str(db_usn), str(current_usn), str(server_usn)), 'usn') - return notes_already_up_to_date + def proceed_start(self, auto_paging=False): + col = self.anki.collection() + lastImport = col.conf.get(SETTINGS.EVERNOTE.LAST_IMPORT, None) + col.conf[SETTINGS.EVERNOTE.LAST_IMPORT] = datetime.now().strftime(ANKNOTES.DATE_FORMAT) + col.setMod() + col.save() + lastImportStr = get_friendly_interval_string(lastImport) + if lastImportStr: lastImportStr = ' [LAST IMPORT: %s]' % lastImportStr + log_banner(" > Starting Evernote Import: Page %3s Query: %s".ljust(122) % ( + '#' + str(self.currentPage), settings.generate_evernote_query()) + lastImportStr, append_newline=False, chr='=', + length=0, center=False, clear=False, timestamp=True) + # log("! > Starting Evernote Import: Page %3s Query: %s".ljust(123) % ( + # '#' + str(self.currentPage), settings.generate_evernote_query()) + ' ' + lastImportStr) + # log("-"*(ANKNOTES.FORMATTING.LINE_LENGTH+1), timestamp=False) + if auto_paging: return True + notestore_status = self.evernote.initialize_note_store() + if not notestore_status == EvernoteAPIStatus.Success: + log(" > Note store does not exist. Aborting.") + show_tooltip("Could not connect to Evernote servers (Status Code: %s)... Aborting." % notestore_status.name) + return False + self.evernote.getNoteCount = 0 + return True - def proceed(self, auto_paging=False): - self.proceed_start(auto_paging) - self.proceed_find_metadata(auto_paging) - self.proceed_import_notes() - self.proceed_autopage() + def proceed_find_metadata(self, auto_paging=False): + global latestEDAMRateLimit, latestSocketError - def proceed_start(self, auto_paging=False): - col = self.anki.collection() - lastImport = col.conf.get(SETTINGS.EVERNOTE.LAST_IMPORT, None) - col.conf[SETTINGS.EVERNOTE.LAST_IMPORT] = datetime.now().strftime(ANKNOTES.DATE_FORMAT) - col.setMod() - col.save() - lastImportStr = get_friendly_interval_string(lastImport) - if lastImportStr: lastImportStr = ' [LAST IMPORT: %s]' % lastImportStr - log_banner(" > Starting Evernote Import: Page %3s Query: %s".ljust(122) % ( - '#' + str(self.currentPage), settings.generate_evernote_query()) + lastImportStr, append_newline=False, chr='=', length=0, center=False, clear=False, timestamp=True) - # log("! > Starting Evernote Import: Page %3s Query: %s".ljust(123) % ( - # '#' + str(self.currentPage), settings.generate_evernote_query()) + ' ' + lastImportStr) - # log("-"*(ANKNOTES.FORMATTING.LINE_LENGTH+1), timestamp=False) - if auto_paging: return True - notestore_status = self.evernote.initialize_note_store() - if not notestore_status == EvernoteAPIStatus.Success: - log(" > Note store does not exist. Aborting.") - show_tooltip("Could not connect to Evernote servers (Status Code: %s)... Aborting." % notestore_status.name) - return False - self.evernote.getNoteCount = 0 - return True + if self.ManualMetadataMode: + self.override_evernote_metadata() + else: + self.get_evernote_metadata() - def proceed_find_metadata(self, auto_paging=False): - global latestEDAMRateLimit, latestSocketError + if self.MetadataProgress.Status == EvernoteAPIStatus.RateLimitError: + m, s = divmod(latestEDAMRateLimit, 60) + show_report(" > Error: Delaying Operation", + "Over the rate limit when searching for Evernote metadata<BR>Evernote requested we wait %d:%02d min" % ( + m, s), delay=5) + mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(auto_paging), False) + return False + elif self.MetadataProgress.Status == EvernoteAPIStatus.SocketError: + show_report(" > Error: Delaying Operation:", + "%s when searching for Evernote metadata" % + latestSocketError['friendly_error_msg'], "We will try again in 30 seconds", delay=5) + mw.progress.timer(30000, lambda: self.proceed(auto_paging), False) + return False - if self.ManualMetadataMode: self.override_evernote_metadata() - else: self.get_evernote_metadata() - - if self.MetadataProgress.Status == EvernoteAPIStatus.RateLimitError: - m, s = divmod(latestEDAMRateLimit, 60) - show_report(" > Error: Delaying Operation", - "Over the rate limit when searching for Evernote metadata<BR>Evernote requested we wait %d:%02d min" % ( - m, s), delay=5) - mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(auto_paging), False) - return False - elif self.MetadataProgress.Status == EvernoteAPIStatus.SocketError: - show_report(" > Error: Delaying Operation:", - "%s when searching for Evernote metadata" % - latestSocketError['friendly_error_msg'], "We will try again in 30 seconds", delay=5) - mw.progress.timer(30000, lambda: self.proceed(auto_paging), False) - return False + self.ImportProgress = EvernoteImportProgress(self.anki, self.MetadataProgress) + self.ImportProgress.loadAlreadyUpdated( + [] if self.ManualMetadataMode else self.check_note_sync_status( + self.ImportProgress.GUIDs.Server.Existing.All)) + log(self.ImportProgress.Summary + "\n", line_padding_header="- Note Sync Status: ", + line_padding=ANKNOTES.FORMATTING.LINE_PADDING_HEADER, timestamp=False) - self.ImportProgress = EvernoteImportProgress(self.anki, self.MetadataProgress) - self.ImportProgress.loadAlreadyUpdated( - [] if self.ManualMetadataMode else self.check_note_sync_status(self.ImportProgress.GUIDs.Server.Existing.All)) - log(self.ImportProgress.Summary + "\n", line_padding_header="- Note Sync Status: ", line_padding=ANKNOTES.FORMATTING.LINE_PADDING_HEADER, timestamp=False) + def proceed_import_notes(self): + self.anki.start_editing() + self.ImportProgress.processResults(self.import_into_anki(self.ImportProgress.GUIDs.Server.New)) + if self.updateExistingNotes == UpdateExistingNotes.UpdateNotesInPlace: + self.ImportProgress.processUpdateInPlaceResults( + self.update_in_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) + elif self.updateExistingNotes == UpdateExistingNotes.DeleteAndReAddNotes: + self.anki.delete_anki_cards(self.ImportProgress.GUIDs.Server.Existing.OutOfDate) + self.ImportProgress.processDeleteAndUpdateResults( + self.import_into_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) + show_report(" > Import Complete", self.ImportProgress.ResultsSummaryLines) + self.anki.stop_editing() + self.anki.collection().autosave() - def proceed_import_notes(self): - self.anki.start_editing() - self.ImportProgress.processResults(self.import_into_anki(self.ImportProgress.GUIDs.Server.New)) - if self.updateExistingNotes == UpdateExistingNotes.UpdateNotesInPlace: - self.ImportProgress.processUpdateInPlaceResults(self.update_in_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) - elif self.updateExistingNotes == UpdateExistingNotes.DeleteAndReAddNotes: - self.anki.delete_anki_cards(self.ImportProgress.GUIDs.Server.Existing.OutOfDate) - self.ImportProgress.processDeleteAndUpdateResults(self.import_into_anki(self.ImportProgress.GUIDs.Server.Existing.OutOfDate)) - show_report(" > Import Complete", self.ImportProgress.ResultsSummaryLines) - self.anki.stop_editing() - self.anki.collection().autosave() + def save_current_page(self): + if self.forceAutoPage: return + col = self.anki.collection() + col.conf[SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE] = self.currentPage + col.setMod() + col.save() - def save_current_page(self): - if self.forceAutoPage: return - col = self.anki.collection() - col.conf[SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE] = self.currentPage - col.setMod() - col.save() - - def proceed_autopage(self): - if not self.autoPagingEnabled: - return - global latestEDAMRateLimit, latestSocketError - status = self.ImportProgress.Status - restart = 0 - if status == EvernoteAPIStatus.RateLimitError: - m, s = divmod(latestEDAMRateLimit, 60) - show_report(" > Error: Delaying Auto Paging", - "Over the rate limit when getting Evernote notes<BR>Evernote requested we wait %d:%02d min" % ( - m, s), delay=5) - mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(True), False) - return False - if status == EvernoteAPIStatus.SocketError: - show_report(" > Error: Delaying Auto Paging:", - "%s when getting Evernote notes" % latestSocketError[ - 'friendly_error_msg'], - "We will try again in 30 seconds", delay=5) - mw.progress.timer(30000, lambda: self.proceed(True), False) - return False - if self.MetadataProgress.IsFinished: - self.currentPage = 1 - if self.forceAutoPage: - show_report(" > Terminating Auto Paging", - "All %d notes have been processed and forceAutoPage is True" % self.MetadataProgress.Total, - delay=5) - if self.auto_page_callback: - self.auto_page_callback() - return True - elif mw.col.conf.get(EVERNOTE.IMPORT.PAGING.RESTART.ENABLED, True): - restart = max(EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL, 60*15) - restart_title = " > Restarting Auto Paging" - restart_msg = "All %d notes have been processed and EVERNOTE.IMPORT.PAGING.RESTART.ENABLED is True<BR>" % \ - self.MetadataProgress.Total - suffix = "Per EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL, " - else: - show_report(" > Completed Auto Paging", - "All %d notes have been processed and EVERNOTE.IMPORT.PAGING.RESTART.ENABLED is False" % - self.MetadataProgress.Total, delay=5) - self.save_current_page() - return True - else: # Paging still in progress (else to ) - self.currentPage = self.MetadataProgress.Page + 1 - restart_title = " > Continuing Auto Paging" - restart_msg = "Page %d completed<BR>%d notes remain over %d page%s<BR>%d of %d notes have been processed" % ( - self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.RemainingPages, 's' if self.MetadataProgress.RemainingPages > 1 else '', self.MetadataProgress.Completed, self.MetadataProgress.Total) - restart = -1 * max(30, EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL_OVERRIDE) - if self.forceAutoPage: - suffix = "<BR>Only delaying {interval} as the forceAutoPage flag is set" - elif self.ImportProgress.APICallCount < EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS: - suffix = "<BR>Only delaying {interval} as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS" % ( - self.ImportProgress.APICallCount, EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS) - else: - restart = max(EVERNOTE.IMPORT.PAGING.INTERVAL_SANDBOX,60*5) if EVERNOTE.API.IS_SANDBOXED else max(EVERNOTE.IMPORT.PAGING.INTERVAL, 60*10) - suffix = "<BR>Delaying Auto Paging: Per EVERNOTE.IMPORT.PAGING.INTERVAL, " - self.save_current_page() - if restart > 0: suffix += "will delay for {interval} before continuing" - m, s = divmod(abs(restart), 60) - suffix = suffix.format(interval=['%2ds' % s,'%d:%02d min'%(m,s)][m>0]) - show_report(restart_title, (restart_msg + suffix).split('<BR>'), delay=5) - if restart: mw.progress.timer(abs(restart) * 1000, lambda: self.proceed(True), False); return False - return self.proceed(True) + def proceed_autopage(self): + if not self.autoPagingEnabled: + return + global latestEDAMRateLimit, latestSocketError + status = self.ImportProgress.Status + restart = 0 + if status == EvernoteAPIStatus.RateLimitError: + m, s = divmod(latestEDAMRateLimit, 60) + show_report(" > Error: Delaying Auto Paging", + "Over the rate limit when getting Evernote notes<BR>Evernote requested we wait %d:%02d min" % ( + m, s), delay=5) + mw.progress.timer(latestEDAMRateLimit * 1000 + 10000, lambda: self.proceed(True), False) + return False + if status == EvernoteAPIStatus.SocketError: + show_report(" > Error: Delaying Auto Paging:", + "%s when getting Evernote notes" % latestSocketError[ + 'friendly_error_msg'], + "We will try again in 30 seconds", delay=5) + mw.progress.timer(30000, lambda: self.proceed(True), False) + return False + if self.MetadataProgress.IsFinished: + self.currentPage = 1 + if self.forceAutoPage: + show_report(" > Terminating Auto Paging", + "All %d notes have been processed and forceAutoPage is True" % self.MetadataProgress.Total, + delay=5) + if self.auto_page_callback: + self.auto_page_callback() + return True + elif mw.col.conf.get(EVERNOTE.IMPORT.PAGING.RESTART.ENABLED, True): + restart = max(EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL, 60 * 15) + restart_title = " > Restarting Auto Paging" + restart_msg = "All %d notes have been processed and EVERNOTE.IMPORT.PAGING.RESTART.ENABLED is True<BR>" % \ + self.MetadataProgress.Total + suffix = "Per EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL, " + else: + show_report(" > Completed Auto Paging", + "All %d notes have been processed and EVERNOTE.IMPORT.PAGING.RESTART.ENABLED is False" % + self.MetadataProgress.Total, delay=5) + self.save_current_page() + return True + else: # Paging still in progress (else to ) + self.currentPage = self.MetadataProgress.Page + 1 + restart_title = " > Continuing Auto Paging" + restart_msg = "Page %d completed<BR>%d notes remain over %d page%s<BR>%d of %d notes have been processed" % ( + self.MetadataProgress.Page, self.MetadataProgress.Remaining, self.MetadataProgress.RemainingPages, + 's' if self.MetadataProgress.RemainingPages > 1 else '', self.MetadataProgress.Completed, + self.MetadataProgress.Total) + restart = -1 * max(30, EVERNOTE.IMPORT.PAGING.RESTART.INTERVAL_OVERRIDE) + if self.forceAutoPage: + suffix = "<BR>Only delaying {interval} as the forceAutoPage flag is set" + elif self.ImportProgress.APICallCount < EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS: + suffix = "<BR>Only delaying {interval} as the API Call Count of %d is less than the minimum of %d set by EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS" % ( + self.ImportProgress.APICallCount, EVERNOTE.IMPORT.PAGING.RESTART.DELAY_MINIMUM_API_CALLS) + else: + restart = max(EVERNOTE.IMPORT.PAGING.INTERVAL_SANDBOX, 60 * 5) if EVERNOTE.API.IS_SANDBOXED else max( + EVERNOTE.IMPORT.PAGING.INTERVAL, 60 * 10) + suffix = "<BR>Delaying Auto Paging: Per EVERNOTE.IMPORT.PAGING.INTERVAL, " + self.save_current_page() + if restart > 0: suffix += "will delay for {interval} before continuing" + m, s = divmod(abs(restart), 60) + suffix = suffix.format(interval=['%2ds' % s, '%d:%02d min' % (m, s)][m > 0]) + show_report(restart_title, (restart_msg + suffix).split('<BR>'), delay=5) + if restart: mw.progress.timer(abs(restart) * 1000, lambda: self.proceed(True), False); return False + return self.proceed(True) - @property - def autoPagingEnabled(self): - return self.anki.collection().conf.get(SETTINGS.EVERNOTE.AUTO_PAGING, True) or self.forceAutoPage + @property + def autoPagingEnabled(self): + return self.anki.collection().conf.get(SETTINGS.EVERNOTE.AUTO_PAGING, True) or self.forceAutoPage diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index ba2acf9..98217ca 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -11,168 +11,171 @@ class EvernoteNoteFetcher(object): - def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): - """ - - :type evernote: ankEvernote.Evernote - """ - self.__reset_data__() - self.results = EvernoteNoteFetcherResults() - self.result = EvernoteNoteFetcherResult() - self.api_calls = 0 - self.keepEvernoteTags = True - self.deleteQueryTags = True - self.evernoteQueryTags = [] - self.tagsToDelete = [] - self.use_local_db_only = use_local_db_only - self.__update_sequence_number__ = -1 - if evernote: self.evernote = evernote - if not evernote_guid: - self.evernote_guid = "" - return - self.evernote_guid = evernote_guid - if evernote and not self.use_local_db_only: - self.__update_sequence_number__ = self.evernote.metadata[self.evernote_guid].updateSequenceNum - self.getNote() - - def __reset_data__(self): - self.tagNames = [] - self.tagGuids = [] - self.whole_note = None - def UpdateSequenceNum(self): - if self.result.Note: - return self.result.Note.UpdateSequenceNum - return self.__update_sequence_number__ - - def reportSuccess(self, note, source=None): - self.reportResult(EvernoteAPIStatus.Success, note, source) - - def reportResult(self, status=None, note=None, source=None): - if note: - self.result.Note = note - status = EvernoteAPIStatus.Success - if not source: - source = 2 - if status: - self.result.Status = status - if source: - self.result.Source = source - self.results.reportResult(self.result) - - def getNoteLocal(self): - # Check Anknotes database for note - query = "SELECT * FROM %s WHERE guid = '%s'" % ( - TABLES.EVERNOTE.NOTES, self.evernote_guid) - if self.UpdateSequenceNum() > -1: - query += " AND `updateSequenceNum` = %d" % self.UpdateSequenceNum() - db_note = ankDB().first(query) - """:type : sqlite.Row""" - if not db_note: return False - if not self.use_local_db_only: - log(' ' + '-'*14 + ' '*5 + "> getNoteLocal: %s" % db_note['title'], 'api') - assert db_note['guid'] == self.evernote_guid - self.reportSuccess(EvernoteNotePrototype(db_note=db_note), 1) - self.setNoteTags(tag_names=self.result.Note.TagNames) - return True - - def setNoteTags(self, tag_names=None, tag_guids=None): - if not self.keepEvernoteTags: - self.tagNames = [] - self.tagGuids = [] - return - if not tag_names: - if self.tagNames: tag_names = self.tagNames - if not tag_names and self.result.Note: tag_names = self.result.Note.TagNames - if not tag_names and self.whole_note: tag_names = self.whole_note.tagNames - if not tag_names: tag_names = None - # if not tag_names: tag_names = self.tagNames if self.tagNames else (self.result.Note.TagNames if self.result.Note else (self.whole_note.tagNames if self.whole_note else None)) - if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else (self.result.Note.TagGuids if self.result.Note else (self.whole_note.tagGuids if self.whole_note else None)) - self.tagGuids, self.tagNames = self.evernote.get_matching_tag_data(tag_guids, tag_names) - - def addNoteFromServerToDB(self, whole_note=None, tag_names=None): - """ - Adds note to Anknote DB from an Evernote Note object provided by the Evernote API - :type whole_note : evernote.edam.type.ttypes.Note - """ - if whole_note: - self.whole_note = whole_note - if tag_names: - self.tagNames = tag_names - title = self.whole_note.title - log('Adding %s: %s' % (self.whole_note.guid, title), 'ankDB') - content = self.whole_note.content - tag_names = u',' + u','.join(self.tagNames).decode('utf-8') + u',' - if isinstance(title, str): - title = unicode(title, 'utf-8') - if isinstance(content, str): - content = unicode(content, 'utf-8') - if isinstance(tag_names, str): - tag_names = unicode(tag_names, 'utf-8') - title = title.replace(u'\'', u'\'\'') - content = content.replace(u'\'', u'\'\'') - tag_names = tag_names.replace(u'\'', u'\'\'') - if not self.tagGuids: - self.tagGuids = self.whole_note.tagGuids - sql_query_header = u'INSERT OR REPLACE INTO `%s`' % TABLES.EVERNOTE.NOTES - sql_query_header_history = u'INSERT INTO `%s`' % TABLES.EVERNOTE.NOTES_HISTORY - sql_query_columns = u'(`guid`,`title`,`content`,`updated`,`created`,`updateSequenceNum`,`notebookGuid`,`tagGuids`,`tagNames`) VALUES (\'%s\',\'%s\',\'%s\',%d,%d,%d,\'%s\',\'%s\',\'%s\');' % ( - self.whole_note.guid.decode('utf-8'), title, content, self.whole_note.updated, self.whole_note.created, - self.whole_note.updateSequenceNum, self.whole_note.notebookGuid.decode('utf-8'), - u',' + u','.join(self.tagGuids).decode('utf-8') + u',', tag_names) - sql_query = sql_query_header + sql_query_columns - log_sql('UPDATE_ANKI_DB: Add Note: SQL Query: ' + sql_query) - ankDB().execute(sql_query) - sql_query = sql_query_header_history + sql_query_columns - ankDB().execute(sql_query) - ankDB().commit() - - def getNoteRemoteAPICall(self): - notestore_status = self.evernote.initialize_note_store() - if not notestore_status.IsSuccess: - self.reportResult(notestore_status) - return False - api_action_str = u'trying to retrieve a note. We will save the notes downloaded thus far.' - self.api_calls += 1 - log_api(" > getNote [%3d]" % self.api_calls, self.evernote_guid) - try: - self.whole_note = self.evernote.noteStore.getNote(self.evernote.token, self.evernote_guid, True, False, - False, False) - """:type : evernote.edam.type.ttypes.Note""" - except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - self.reportResult(EvernoteAPIStatus.RateLimitError) - return False - except socket.error, v: - if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - self.reportResult(EvernoteAPIStatus.SocketError) - return False - assert self.whole_note.guid == self.evernote_guid - return True - - def getNoteRemote(self): - if self.api_calls > EVERNOTE.IMPORT.API_CALLS_LIMIT > -1: - log("Aborting Evernote.getNoteRemote: EVERNOTE.IMPORT.API_CALLS_LIMIT of %d has been reached" % EVERNOTE.IMPORT.API_CALLS_LIMIT) - return None - if not self.getNoteRemoteAPICall(): return False - # self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_evernote_guids(self.whole_note.tagGuids) - self.setNoteTags(tag_guids=self.whole_note.tagGuids) - self.addNoteFromServerToDB() - if not self.keepEvernoteTags: self.tagNames = [] - self.reportSuccess(EvernoteNotePrototype(whole_note=self.whole_note, tags=self.tagNames)) - return True - - def setNote(self, whole_note): - self.whole_note = whole_note - self.addNoteFromServerToDB() - - def getNote(self, evernote_guid=None): - self.__reset_data__() - if evernote_guid: - self.result.Note = None - self.evernote_guid = evernote_guid - self.evernote.evernote_guid = evernote_guid - self.__update_sequence_number__ = self.evernote.metadata[ - self.evernote_guid].updateSequenceNum if not self.use_local_db_only else -1 - if self.getNoteLocal(): return True - if self.use_local_db_only: return False - return self.getNoteRemote() + def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): + """ + + :type evernote: ankEvernote.Evernote + """ + self.__reset_data__() + self.results = EvernoteNoteFetcherResults() + self.result = EvernoteNoteFetcherResult() + self.api_calls = 0 + self.keepEvernoteTags = True + self.deleteQueryTags = True + self.evernoteQueryTags = [] + self.tagsToDelete = [] + self.use_local_db_only = use_local_db_only + self.__update_sequence_number__ = -1 + if evernote: self.evernote = evernote + if not evernote_guid: + self.evernote_guid = "" + return + self.evernote_guid = evernote_guid + if evernote and not self.use_local_db_only: + self.__update_sequence_number__ = self.evernote.metadata[self.evernote_guid].updateSequenceNum + self.getNote() + + def __reset_data__(self): + self.tagNames = [] + self.tagGuids = [] + self.whole_note = None + + def UpdateSequenceNum(self): + if self.result.Note: + return self.result.Note.UpdateSequenceNum + return self.__update_sequence_number__ + + def reportSuccess(self, note, source=None): + self.reportResult(EvernoteAPIStatus.Success, note, source) + + def reportResult(self, status=None, note=None, source=None): + if note: + self.result.Note = note + status = EvernoteAPIStatus.Success + if not source: + source = 2 + if status: + self.result.Status = status + if source: + self.result.Source = source + self.results.reportResult(self.result) + + def getNoteLocal(self): + # Check Anknotes database for note + query = "SELECT * FROM %s WHERE guid = '%s'" % ( + TABLES.EVERNOTE.NOTES, self.evernote_guid) + if self.UpdateSequenceNum() > -1: + query += " AND `updateSequenceNum` = %d" % self.UpdateSequenceNum() + db_note = ankDB().first(query) + """:type : sqlite.Row""" + if not db_note: return False + if not self.use_local_db_only: + log(' ' + '-' * 14 + ' ' * 5 + "> getNoteLocal: %s" % db_note['title'], 'api') + assert db_note['guid'] == self.evernote_guid + self.reportSuccess(EvernoteNotePrototype(db_note=db_note), 1) + self.setNoteTags(tag_names=self.result.Note.TagNames) + return True + + def setNoteTags(self, tag_names=None, tag_guids=None): + if not self.keepEvernoteTags: + self.tagNames = [] + self.tagGuids = [] + return + if not tag_names: + if self.tagNames: tag_names = self.tagNames + if not tag_names and self.result.Note: tag_names = self.result.Note.TagNames + if not tag_names and self.whole_note: tag_names = self.whole_note.tagNames + if not tag_names: tag_names = None + # if not tag_names: tag_names = self.tagNames if self.tagNames else (self.result.Note.TagNames if self.result.Note else (self.whole_note.tagNames if self.whole_note else None)) + if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else ( + self.result.Note.TagGuids if self.result.Note else (self.whole_note.tagGuids if self.whole_note else None)) + self.tagGuids, self.tagNames = self.evernote.get_matching_tag_data(tag_guids, tag_names) + + def addNoteFromServerToDB(self, whole_note=None, tag_names=None): + """ + Adds note to Anknote DB from an Evernote Note object provided by the Evernote API + :type whole_note : evernote.edam.type.ttypes.Note + """ + if whole_note: + self.whole_note = whole_note + if tag_names: + self.tagNames = tag_names + title = self.whole_note.title + log('Adding %s: %s' % (self.whole_note.guid, title), 'ankDB') + content = self.whole_note.content + tag_names = u',' + u','.join(self.tagNames).decode('utf-8') + u',' + if isinstance(title, str): + title = unicode(title, 'utf-8') + if isinstance(content, str): + content = unicode(content, 'utf-8') + if isinstance(tag_names, str): + tag_names = unicode(tag_names, 'utf-8') + title = title.replace(u'\'', u'\'\'') + content = content.replace(u'\'', u'\'\'') + tag_names = tag_names.replace(u'\'', u'\'\'') + if not self.tagGuids: + self.tagGuids = self.whole_note.tagGuids + sql_query_header = u'INSERT OR REPLACE INTO `%s`' % TABLES.EVERNOTE.NOTES + sql_query_header_history = u'INSERT INTO `%s`' % TABLES.EVERNOTE.NOTES_HISTORY + sql_query_columns = u'(`guid`,`title`,`content`,`updated`,`created`,`updateSequenceNum`,`notebookGuid`,`tagGuids`,`tagNames`) VALUES (\'%s\',\'%s\',\'%s\',%d,%d,%d,\'%s\',\'%s\',\'%s\');' % ( + self.whole_note.guid.decode('utf-8'), title, content, self.whole_note.updated, self.whole_note.created, + self.whole_note.updateSequenceNum, self.whole_note.notebookGuid.decode('utf-8'), + u',' + u','.join(self.tagGuids).decode('utf-8') + u',', tag_names) + sql_query = sql_query_header + sql_query_columns + log_sql('UPDATE_ANKI_DB: Add Note: SQL Query: ' + sql_query) + ankDB().execute(sql_query) + sql_query = sql_query_header_history + sql_query_columns + ankDB().execute(sql_query) + ankDB().commit() + + def getNoteRemoteAPICall(self): + notestore_status = self.evernote.initialize_note_store() + if not notestore_status.IsSuccess: + self.reportResult(notestore_status) + return False + api_action_str = u'trying to retrieve a note. We will save the notes downloaded thus far.' + self.api_calls += 1 + log_api(" > getNote [%3d]" % self.api_calls, self.evernote_guid) + try: + self.whole_note = self.evernote.noteStore.getNote(self.evernote.token, self.evernote_guid, True, False, + False, False) + """:type : evernote.edam.type.ttypes.Note""" + except EDAMSystemException as e: + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + self.reportResult(EvernoteAPIStatus.RateLimitError) + return False + except socket.error, v: + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + self.reportResult(EvernoteAPIStatus.SocketError) + return False + assert self.whole_note.guid == self.evernote_guid + return True + + def getNoteRemote(self): + if self.api_calls > EVERNOTE.IMPORT.API_CALLS_LIMIT > -1: + log( + "Aborting Evernote.getNoteRemote: EVERNOTE.IMPORT.API_CALLS_LIMIT of %d has been reached" % EVERNOTE.IMPORT.API_CALLS_LIMIT) + return None + if not self.getNoteRemoteAPICall(): return False + # self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_evernote_guids(self.whole_note.tagGuids) + self.setNoteTags(tag_guids=self.whole_note.tagGuids) + self.addNoteFromServerToDB() + if not self.keepEvernoteTags: self.tagNames = [] + self.reportSuccess(EvernoteNotePrototype(whole_note=self.whole_note, tags=self.tagNames)) + return True + + def setNote(self, whole_note): + self.whole_note = whole_note + self.addNoteFromServerToDB() + + def getNote(self, evernote_guid=None): + self.__reset_data__() + if evernote_guid: + self.result.Note = None + self.evernote_guid = evernote_guid + self.evernote.evernote_guid = evernote_guid + self.__update_sequence_number__ = self.evernote.metadata[ + self.evernote_guid].updateSequenceNum if not self.use_local_db_only else -1 + if self.getNoteLocal(): return True + if self.use_local_db_only: return False + return self.getNoteRemote() diff --git a/anknotes/EvernoteNotePrototype.py b/anknotes/EvernoteNotePrototype.py index c3f74a2..d3782fa 100644 --- a/anknotes/EvernoteNotePrototype.py +++ b/anknotes/EvernoteNotePrototype.py @@ -3,132 +3,135 @@ from anknotes.structs import upperFirst, EvernoteAPIStatus from anknotes.logging import log, log_blank, log_error + class EvernoteNotePrototype: - ################## CLASS Note ################ - Title = None - """:type: EvernoteNoteTitle""" - Content = "" - Guid = "" - UpdateSequenceNum = -1 - """:type: int""" - TagNames = [] - TagGuids = [] - NotebookGuid = None - Status = EvernoteAPIStatus.Uninitialized - """:type : EvernoteAPIStatus """ - Children = [] - - @property - def Tags(self): - return self.TagNames - - def process_tags(self): - if isinstance(self.TagNames, str) or isinstance(self.TagNames, unicode): - self.TagNames = self.TagNames[1:-1].split(',') - if isinstance(self.TagGuids, str) or isinstance(self.TagGuids, unicode): - self.TagGuids = self.TagGuids[1:-1].split(',') - - def __repr__(self): - return u"<EN Note: %s: '%s'>" % (self.Guid, self.Title) - - def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid=None, updateSequenceNum=None, - whole_note=None, db_note=None): - """ - - :type whole_note: evernote.edam.type.ttypes.Note - :type db_note: sqlite3.dbapi2.Row - """ - - self.Status = EvernoteAPIStatus.Uninitialized - self.TagNames = tags - if whole_note is not None: - if self.TagNames is None: self.TagNames = whole_note.tagNames - self.Title = EvernoteNoteTitle(whole_note) - self.Content = whole_note.content - self.Guid = whole_note.guid - self.NotebookGuid = whole_note.notebookGuid - self.UpdateSequenceNum = whole_note.updateSequenceNum - self.Status = EvernoteAPIStatus.Success - return - if db_note is not None: - self.Title = EvernoteNoteTitle(db_note) - db_note_keys = db_note.keys() - for key in ['content', 'guid', 'notebookGuid', 'updateSequenceNum', 'tagNames', 'tagGuids']: - if not key in db_note_keys: - log_error("FATAL ERROR: Unable to find key %s in db note %s! \n%s" % (key, self.FullTitle, db_note_keys)) - log("Values: \n\n" + str({k: db_note[k] for k in db_note_keys }), 'EvernoteNotePrototypeInit') - else: - setattr(self, upperFirst(key), db_note[key]) - if isinstance(self.TagNames, str): - self.TagNames = unicode(self.TagNames, 'utf-8') - if isinstance(self.Content, str): - self.Content = unicode(self.Content, 'utf-8') - self.process_tags() - self.Status = EvernoteAPIStatus.Success - return - self.Title = EvernoteNoteTitle(title) - self.Content = content - self.Guid = guid - self.NotebookGuid = notebookGuid - self.UpdateSequenceNum = updateSequenceNum - self.Status = EvernoteAPIStatus.Manual - - def generateURL(self): - return generate_evernote_url(self.Guid) - - def generateLink(self, value=None): - return generate_evernote_link(self.Guid, self.Title.Name, value) - - def generateLevelLink(self, value=None): - return generate_evernote_link_by_level(self.Guid, self.Title.Name, value) - - ### Shortcuts to EvernoteNoteTitle Properties; Autogenerated with regex /def +(\w+)\(\)\:/def \1\(\):\r\n\treturn self.Title.\1\r\n/ - @property - def Level(self): - return self.Title.Level - - @property - def Depth(self): - return self.Title.Depth - - @property - def FullTitle(self): return self.Title.FullTitle - - @property - def Name(self): - return self.Title.Name - - @property - def Root(self): - return self.Title.Root - - @property - def Base(self): - return self.Title.Base - - @property - def Parent(self): - return self.Title.Parent - - @property - def TitleParts(self): - return self.Title.TitleParts - - @property - def IsChild(self): - return self.Title.IsChild - - @property - def IsRoot(self): - return self.Title.IsRoot - - def IsAboveLevel(self, level_check): - return self.Title.IsAboveLevel(level_check) - - def IsBelowLevel(self, level_check): - return self.Title.IsBelowLevel(level_check) - - def IsLevel(self, level_check): - return self.Title.IsLevel(level_check) - - ################## END CLASS Note ################ + ################## CLASS Note ################ + Title = None + """:type: EvernoteNoteTitle""" + Content = "" + Guid = "" + UpdateSequenceNum = -1 + """:type: int""" + TagNames = [] + TagGuids = [] + NotebookGuid = None + Status = EvernoteAPIStatus.Uninitialized + """:type : EvernoteAPIStatus """ + Children = [] + + @property + def Tags(self): + return self.TagNames + + def process_tags(self): + if isinstance(self.TagNames, str) or isinstance(self.TagNames, unicode): + self.TagNames = self.TagNames[1:-1].split(',') + if isinstance(self.TagGuids, str) or isinstance(self.TagGuids, unicode): + self.TagGuids = self.TagGuids[1:-1].split(',') + + def __repr__(self): + return u"<EN Note: %s: '%s'>" % (self.Guid, self.Title) + + def __init__(self, title=None, content=None, guid=None, tags=None, notebookGuid=None, updateSequenceNum=None, + whole_note=None, db_note=None): + """ + + :type whole_note: evernote.edam.type.ttypes.Note + :type db_note: sqlite3.dbapi2.Row + """ + + self.Status = EvernoteAPIStatus.Uninitialized + self.TagNames = tags + if whole_note is not None: + if self.TagNames is None: self.TagNames = whole_note.tagNames + self.Title = EvernoteNoteTitle(whole_note) + self.Content = whole_note.content + self.Guid = whole_note.guid + self.NotebookGuid = whole_note.notebookGuid + self.UpdateSequenceNum = whole_note.updateSequenceNum + self.Status = EvernoteAPIStatus.Success + return + if db_note is not None: + self.Title = EvernoteNoteTitle(db_note) + db_note_keys = db_note.keys() + for key in ['content', 'guid', 'notebookGuid', 'updateSequenceNum', 'tagNames', 'tagGuids']: + if not key in db_note_keys: + log_error( + "FATAL ERROR: Unable to find key %s in db note %s! \n%s" % (key, self.FullTitle, db_note_keys)) + log("Values: \n\n" + str({k: db_note[k] for k in db_note_keys}), 'EvernoteNotePrototypeInit') + else: + setattr(self, upperFirst(key), db_note[key]) + if isinstance(self.TagNames, str): + self.TagNames = unicode(self.TagNames, 'utf-8') + if isinstance(self.Content, str): + self.Content = unicode(self.Content, 'utf-8') + self.process_tags() + self.Status = EvernoteAPIStatus.Success + return + self.Title = EvernoteNoteTitle(title) + self.Content = content + self.Guid = guid + self.NotebookGuid = notebookGuid + self.UpdateSequenceNum = updateSequenceNum + self.Status = EvernoteAPIStatus.Manual + + def generateURL(self): + return generate_evernote_url(self.Guid) + + def generateLink(self, value=None): + return generate_evernote_link(self.Guid, self.Title.Name, value) + + def generateLevelLink(self, value=None): + return generate_evernote_link_by_level(self.Guid, self.Title.Name, value) + + ### Shortcuts to EvernoteNoteTitle Properties; Autogenerated with regex /def +(\w+)\(\)\:/def \1\(\):\r\n\treturn self.Title.\1\r\n/ + @property + def Level(self): + return self.Title.Level + + @property + def Depth(self): + return self.Title.Depth + + @property + def FullTitle(self): + return self.Title.FullTitle + + @property + def Name(self): + return self.Title.Name + + @property + def Root(self): + return self.Title.Root + + @property + def Base(self): + return self.Title.Base + + @property + def Parent(self): + return self.Title.Parent + + @property + def TitleParts(self): + return self.Title.TitleParts + + @property + def IsChild(self): + return self.Title.IsChild + + @property + def IsRoot(self): + return self.Title.IsRoot + + def IsAboveLevel(self, level_check): + return self.Title.IsAboveLevel(level_check) + + def IsBelowLevel(self, level_check): + return self.Title.IsBelowLevel(level_check) + + def IsLevel(self, level_check): + return self.Title.IsLevel(level_check) + + ################## END CLASS Note ################ diff --git a/anknotes/EvernoteNoteTitle.py b/anknotes/EvernoteNoteTitle.py index 65976e3..e550a6e 100644 --- a/anknotes/EvernoteNoteTitle.py +++ b/anknotes/EvernoteNoteTitle.py @@ -5,225 +5,228 @@ def generateTOCTitle(title): - title = EvernoteNoteTitle.titleObjectToString(title).upper() - for chr in u'αβδφḃ': - title = title.replace(chr.upper(), chr) - return title + title = EvernoteNoteTitle.titleObjectToString(title).upper() + for chr in u'αβδφḃ': + title = title.replace(chr.upper(), chr) + return title + class EvernoteNoteTitle: - level = 0 - __title__ = "" - """:type: str""" - __titleParts__ = None - """:type: list[str]""" - - # # Parent = None - # def __str__(self): - # return "%d: %s" % (self.Level(), self.Title) - - def __repr__(self): - return "<%s:%s>" % (self.__class__.__name__, self.FullTitle) - - @property - def TitleParts(self): - if not self.FullTitle: return [] - if not self.__titleParts__: self.__titleParts__ = generateTitleParts(self.FullTitle) - return self.__titleParts__ - - @property - def Level(self): - """ - :rtype: int - :return: Current Level with 1 being the Root Title - """ - if not self.level: self.level = len(self.TitleParts) - return self.level - - @property - def Depth(self): - return self.Level - 1 - - def Parts(self, level=-1): - return self.Slice(level) - - def Part(self, level=-1): - mySlice = self.Parts(level) - if not mySlice: return None - return mySlice.Root - - def BaseParts(self, level=None): - return self.Slice(1, level) - - def Parents(self, level=-1): - # noinspection PyTypeChecker - return self.Slice(None, level) - - def Names(self, level=-1): - return self.Parts(level) - - @property - def TOCTitle(self): - return generateTOCTitle(self.FullTitle) - - @property - def TOCName(self): - return generateTOCTitle(self.Name) - - @property - def TOCRootTitle(self): - return generateTOCTitle(self.Root) - - @property - def Name(self): - return self.Part() - - @property - def Root(self): - return self.Parents(1).FullTitle - - @property - def Base(self): - return self.BaseParts() - - def Slice(self, start=0, end=None): - # print "Slicing: <%s> %s ~ %d,%d" % (type(self.Title), self.Title, start, end) - oldParts = self.TitleParts - # print "Slicing: %s ~ %d,%d from parts %s" % (self.Title, start, end, str(oldParts)) - assert self.FullTitle and oldParts - if start is None and end is None: - print "Slicing: %s ~ %d,%d from parts %s" % (self.FullTitle, start, end, str(oldParts)) - assert start is not None or end is not None - newParts = oldParts[start:end] - if len(newParts) == 0: - log_error("Slice failed for %s-%s of %s" % (str(start), str(end), self.FullTitle)) - # return None - assert len(newParts) > 0 - newStr = ': '.join(newParts) - # print "Slice: Just created new title %s from %s" % (newStr , self.Title) - return EvernoteNoteTitle(newStr) - - @property - def Parent(self): - return self.Parents() - - def IsAboveLevel(self, level_check): - return self.Level > level_check - - def IsBelowLevel(self, level_check): - return self.Level < level_check - - def IsLevel(self, level_check): - return self.Level == level_check - - @property - def IsChild(self): - return self.IsAboveLevel(1) - - @property - def IsRoot(self): - return self.IsLevel(1) - - @staticmethod - def titleObjectToString(title, recursion=0): - """ - :param title: Title in string, unicode, dict, sqlite, TOCKey or NoteTitle formats. Note objects are also parseable - :type title: None | str | unicode | dict[str,str] | sqlite.Row | EvernoteNoteTitle - :return: string Title - :rtype: str - """ - # if recursion == 0: - # strr = str_safe(title) - # try: log(u'\n---------------------------------%s' % strr, 'tOTS', timestamp=False) - # except: log(u'\n---------------------------------%s' % '[UNABLE TO DISPLAY TITLE]', 'tOTS', timestamp=False) - # pass - - if title is None: - # log('NoneType', 'tOTS', timestamp=False) - return "" - if isinstance(title, str) or isinstance(title, unicode): - # log('str/unicode', 'tOTS', timestamp=False) - return title - if hasattr(title, 'FullTitle'): - # log('FullTitle', 'tOTS', timestamp=False) - # noinspection PyCallingNonCallable - title = title.FullTitle() if callable(title.FullTitle) else title.FullTitle - elif hasattr(title, 'Title'): - # log('Title', 'tOTS', timestamp=False) - title = title.Title() if callable(title.Title) else title.Title - elif hasattr(title, 'title'): - # log('title', 'tOTS', timestamp=False) - title = title.title() if callable(title.title) else title.title - else: - try: - if hasattr(title, 'keys'): - keys = title.keys() if callable(title.keys) else title.keys - if 'title' in keys: - # log('keys[title]', 'tOTS', timestamp=False) - title = title['title'] - elif 'Title' in keys: - # log('keys[Title]', 'tOTS', timestamp=False) - title = title['Title'] - elif len(keys) == 0: - # log('keys[empty dict?]', 'tOTS', timestamp=False) - raise - else: - log('keys[Unknown Attr]: %s' % str(keys), 'tOTS', timestamp=False) - return "" - elif 'title' in title: - # log('[title]', 'tOTS', timestamp=False) - title = title['title'] - elif 'Title' in title: - # log('[Title]', 'tOTS', timestamp=False) - title = title['Title'] - elif FIELDS.TITLE in title: - # log('[FIELDS.TITLE]', 'tOTS', timestamp=False) - title = title[FIELDS.TITLE] - else: - # log('Nothing Found', 'tOTS', timestamp=False) - # log(title) - # log(title.keys()) - return title - except: - log('except', 'tOTS', timestamp=False) - log(title, 'toTS', timestamp=False) - raise LookupError - recursion += 1 - # log(u'recursing %d: ' % recursion, 'tOTS', timestamp=False) - return EvernoteNoteTitle.titleObjectToString(title, recursion) - - @property - def FullTitle(self): - """:rtype: str""" - return self.__title__ - - @property - def HTML(self): - return self.__html__ - - def __init__(self, titleObj=None): - """:type titleObj: str | unicode | sqlite.Row | EvernoteNoteTitle | evernote.edam.type.ttypes.Note | EvernoteNotePrototype.EvernoteNotePrototype """ - self.__html__ = self.titleObjectToString(titleObj) - self.__title__ = strip_tags_and_new_lines(self.__html__) + level = 0 + __title__ = "" + """:type: str""" + __titleParts__ = None + """:type: list[str]""" + + # # Parent = None + # def __str__(self): + # return "%d: %s" % (self.Level(), self.Title) + + def __repr__(self): + return "<%s:%s>" % (self.__class__.__name__, self.FullTitle) + + @property + def TitleParts(self): + if not self.FullTitle: return [] + if not self.__titleParts__: self.__titleParts__ = generateTitleParts(self.FullTitle) + return self.__titleParts__ + + @property + def Level(self): + """ + :rtype: int + :return: Current Level with 1 being the Root Title + """ + if not self.level: self.level = len(self.TitleParts) + return self.level + + @property + def Depth(self): + return self.Level - 1 + + def Parts(self, level=-1): + return self.Slice(level) + + def Part(self, level=-1): + mySlice = self.Parts(level) + if not mySlice: return None + return mySlice.Root + + def BaseParts(self, level=None): + return self.Slice(1, level) + + def Parents(self, level=-1): + # noinspection PyTypeChecker + return self.Slice(None, level) + + def Names(self, level=-1): + return self.Parts(level) + + @property + def TOCTitle(self): + return generateTOCTitle(self.FullTitle) + + @property + def TOCName(self): + return generateTOCTitle(self.Name) + + @property + def TOCRootTitle(self): + return generateTOCTitle(self.Root) + + @property + def Name(self): + return self.Part() + + @property + def Root(self): + return self.Parents(1).FullTitle + + @property + def Base(self): + return self.BaseParts() + + def Slice(self, start=0, end=None): + # print "Slicing: <%s> %s ~ %d,%d" % (type(self.Title), self.Title, start, end) + oldParts = self.TitleParts + # print "Slicing: %s ~ %d,%d from parts %s" % (self.Title, start, end, str(oldParts)) + assert self.FullTitle and oldParts + if start is None and end is None: + print + "Slicing: %s ~ %d,%d from parts %s" % (self.FullTitle, start, end, str(oldParts)) + assert start is not None or end is not None + newParts = oldParts[start:end] + if len(newParts) == 0: + log_error("Slice failed for %s-%s of %s" % (str(start), str(end), self.FullTitle)) + # return None + assert len(newParts) > 0 + newStr = ': '.join(newParts) + # print "Slice: Just created new title %s from %s" % (newStr , self.Title) + return EvernoteNoteTitle(newStr) + + @property + def Parent(self): + return self.Parents() + + def IsAboveLevel(self, level_check): + return self.Level > level_check + + def IsBelowLevel(self, level_check): + return self.Level < level_check + + def IsLevel(self, level_check): + return self.Level == level_check + + @property + def IsChild(self): + return self.IsAboveLevel(1) + + @property + def IsRoot(self): + return self.IsLevel(1) + + @staticmethod + def titleObjectToString(title, recursion=0): + """ + :param title: Title in string, unicode, dict, sqlite, TOCKey or NoteTitle formats. Note objects are also parseable + :type title: None | str | unicode | dict[str,str] | sqlite.Row | EvernoteNoteTitle + :return: string Title + :rtype: str + """ + # if recursion == 0: + # strr = str_safe(title) + # try: log(u'\n---------------------------------%s' % strr, 'tOTS', timestamp=False) + # except: log(u'\n---------------------------------%s' % '[UNABLE TO DISPLAY TITLE]', 'tOTS', timestamp=False) + # pass + + if title is None: + # log('NoneType', 'tOTS', timestamp=False) + return "" + if isinstance(title, str) or isinstance(title, unicode): + # log('str/unicode', 'tOTS', timestamp=False) + return title + if hasattr(title, 'FullTitle'): + # log('FullTitle', 'tOTS', timestamp=False) + # noinspection PyCallingNonCallable + title = title.FullTitle() if callable(title.FullTitle) else title.FullTitle + elif hasattr(title, 'Title'): + # log('Title', 'tOTS', timestamp=False) + title = title.Title() if callable(title.Title) else title.Title + elif hasattr(title, 'title'): + # log('title', 'tOTS', timestamp=False) + title = title.title() if callable(title.title) else title.title + else: + try: + if hasattr(title, 'keys'): + keys = title.keys() if callable(title.keys) else title.keys + if 'title' in keys: + # log('keys[title]', 'tOTS', timestamp=False) + title = title['title'] + elif 'Title' in keys: + # log('keys[Title]', 'tOTS', timestamp=False) + title = title['Title'] + elif len(keys) == 0: + # log('keys[empty dict?]', 'tOTS', timestamp=False) + raise + else: + log('keys[Unknown Attr]: %s' % str(keys), 'tOTS', timestamp=False) + return "" + elif 'title' in title: + # log('[title]', 'tOTS', timestamp=False) + title = title['title'] + elif 'Title' in title: + # log('[Title]', 'tOTS', timestamp=False) + title = title['Title'] + elif FIELDS.TITLE in title: + # log('[FIELDS.TITLE]', 'tOTS', timestamp=False) + title = title[FIELDS.TITLE] + else: + # log('Nothing Found', 'tOTS', timestamp=False) + # log(title) + # log(title.keys()) + return title + except: + log('except', 'tOTS', timestamp=False) + log(title, 'toTS', timestamp=False) + raise LookupError + recursion += 1 + # log(u'recursing %d: ' % recursion, 'tOTS', timestamp=False) + return EvernoteNoteTitle.titleObjectToString(title, recursion) + + @property + def FullTitle(self): + """:rtype: str""" + return self.__title__ + + @property + def HTML(self): + return self.__html__ + + def __init__(self, titleObj=None): + """:type titleObj: str | unicode | sqlite.Row | EvernoteNoteTitle | evernote.edam.type.ttypes.Note | EvernoteNotePrototype.EvernoteNotePrototype """ + self.__html__ = self.titleObjectToString(titleObj) + self.__title__ = strip_tags_and_new_lines(self.__html__) + def generateTitleParts(title): - title = EvernoteNoteTitle.titleObjectToString(title) - try: - strTitle = re.sub(':+', ':', title) - except: - log('generateTitleParts Unable to re.sub') - log(type(title)) - raise - if strTitle[-1] == ':': strTitle = strTitle[:-1] - if strTitle[0] == ':': strTitle = strTitle[1:] - partsText = strTitle.split(':') - count = len(partsText) - for i in range(1, count + 1): - txt = partsText[i - 1] - try: - if txt[-1] == ' ': txt = txt[:-1] - if txt[0] == ' ': txt = txt[1:] - except: - print_safe(title + ' -- ' + '"' + txt + '"') - raise - partsText[i - 1] = txt - return partsText + title = EvernoteNoteTitle.titleObjectToString(title) + try: + strTitle = re.sub(':+', ':', title) + except: + log('generateTitleParts Unable to re.sub') + log(type(title)) + raise + if strTitle[-1] == ':': strTitle = strTitle[:-1] + if strTitle[0] == ':': strTitle = strTitle[1:] + partsText = strTitle.split(':') + count = len(partsText) + for i in range(1, count + 1): + txt = partsText[i - 1] + try: + if txt[-1] == ' ': txt = txt[:-1] + if txt[0] == ' ': txt = txt[1:] + except: + print_safe(title + ' -- ' + '"' + txt + '"') + raise + partsText[i - 1] = txt + return partsText diff --git a/anknotes/EvernoteNotes.py b/anknotes/EvernoteNotes.py index 82be22d..4432248 100644 --- a/anknotes/EvernoteNotes.py +++ b/anknotes/EvernoteNotes.py @@ -5,9 +5,9 @@ from anknotes.EvernoteNoteTitle import generateTOCTitle try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite ### Anknotes Imports from anknotes.shared import * @@ -18,410 +18,417 @@ class EvernoteNoteProcessingFlags: - delayProcessing = False - populateRootTitlesList = True - populateRootTitlesDict = True - populateExistingRootTitlesList = False - populateExistingRootTitlesDict = False - populateMissingRootTitlesList = False - populateMissingRootTitlesDict = False - populateChildRootTitles = False - ignoreAutoTOCAsRootTitle = False - ignoreOutlineAsRootTitle = False - - def __init__(self, flags=None): - if isinstance(flags, bool): - if not flags: self.set_default(False) - if flags: self.update(flags) - - def set_default(self, flag): - self.populateRootTitlesList = flag - self.populateRootTitlesDict = flag - - def update(self, flags): - for flag_name, flag_value in flags: - if hasattr(self, flag_name): - setattr(self, flag_name, flag_value) + delayProcessing = False + populateRootTitlesList = True + populateRootTitlesDict = True + populateExistingRootTitlesList = False + populateExistingRootTitlesDict = False + populateMissingRootTitlesList = False + populateMissingRootTitlesDict = False + populateChildRootTitles = False + ignoreAutoTOCAsRootTitle = False + ignoreOutlineAsRootTitle = False + + def __init__(self, flags=None): + if isinstance(flags, bool): + if not flags: self.set_default(False) + if flags: self.update(flags) + + def set_default(self, flag): + self.populateRootTitlesList = flag + self.populateRootTitlesDict = flag + + def update(self, flags): + for flag_name, flag_value in flags: + if hasattr(self, flag_name): + setattr(self, flag_name, flag_value) class EvernoteNotesCollection: - TitlesList = [] - TitlesDict = {} - NotesDict = {} - """:type : dict[str, EvernoteNote.EvernoteNote]""" - ChildNotesDict = {} - """:type : dict[str, EvernoteNote.EvernoteNote]""" - ChildTitlesDict = {} - - def __init__(self): - self.TitlesList = [] - self.TitlesDict = {} - self.NotesDict = {} - self.ChildNotesDict = {} - self.ChildTitlesDict = {} + TitlesList = [] + TitlesDict = {} + NotesDict = {} + """:type : dict[str, EvernoteNote.EvernoteNote]""" + ChildNotesDict = {} + """:type : dict[str, EvernoteNote.EvernoteNote]""" + ChildTitlesDict = {} + + def __init__(self): + self.TitlesList = [] + self.TitlesDict = {} + self.NotesDict = {} + self.ChildNotesDict = {} + self.ChildTitlesDict = {} class EvernoteNotes: - ################## CLASS Notes ################ - Notes = {} - """:type : dict[str, EvernoteNote.EvernoteNote]""" - RootNotes = EvernoteNotesCollection() - RootNotesChildren = EvernoteNotesCollection() - processingFlags = EvernoteNoteProcessingFlags() - baseQuery = "1" - - def __init__(self, delayProcessing=False): - self.processingFlags.delayProcessing = delayProcessing - self.RootNotes = EvernoteNotesCollection() - - def addNoteSilently(self, enNote): - """:type enNote: EvernoteNote.EvernoteNote""" - assert enNote - self.Notes[enNote.Guid] = enNote - - def addNote(self, enNote): - """:type enNote: EvernoteNote.EvernoteNote""" - assert enNote - self.addNoteSilently(enNote) - if self.processingFlags.delayProcessing: return - self.processNote(enNote) - - def addDBNote(self, dbNote): - """:type dbNote: sqlite.Row""" - enNote = EvernoteNotePrototype(db_note=dbNote) - if not enNote: - log(dbNote) - log(dbNote.keys) - log(dir(dbNote)) - assert enNote - self.addNote(enNote) - - def addDBNotes(self, dbNotes): - """:type dbNotes: list[sqlite.Row]""" - for dbNote in dbNotes: - self.addDBNote(dbNote) - - def addDbQuery(self, sql_query, order=''): - sql_query = "SELECT * FROM %s WHERE (%s) AND (%s) " % (TABLES.EVERNOTE.NOTES, self.baseQuery, sql_query) - if order: sql_query += ' ORDER BY ' + order - dbNotes = ankDB().execute(sql_query) - self.addDBNotes(dbNotes) - - @staticmethod - def getNoteFromDB(query): - """ - - :param query: - :return: - :rtype : sqlite.Row - """ - sql_query = "SELECT * FROM %s WHERE %s " % (TABLES.EVERNOTE.NOTES, query) - dbNote = ankDB().first(sql_query) - if not dbNote: return None - return dbNote - - def getNoteFromDBByGuid(self, guid): - sql_query = "guid = '%s' " % guid - return self.getNoteFromDB(sql_query) - - def getEnNoteFromDBByGuid(self, guid): - return EvernoteNotePrototype(db_note=self.getNoteFromDBByGuid(guid)) - - - # def addChildNoteHierarchically(self, enChildNotes, enChildNote): - # parts = enChildNote.Title.TitleParts - # dict_updated = {} - # dict_building = {parts[len(parts)-1]: enChildNote} - # print_safe(parts) - # for i in range(len(parts), 1, -1): - # dict_building = {parts[i - 1]: dict_building} - # log_dump(dict_building) - # enChildNotes.update(dict_building) - # log_dump(enChildNotes) - # return enChildNotes - - def processNote(self, enNote): - """:type enNote: EvernoteNote.EvernoteNote""" - if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict or self.processingFlags.populateMissingRootTitlesList or self.processingFlags.populateMissingRootTitlesDict: - if enNote.IsChild: - # log([enNote.Title, enNote.Level, enNote.Title.TitleParts, enNote.IsChild]) - rootTitle = enNote.Title.Root - rootTitleStr = generateTOCTitle(rootTitle) - if self.processingFlags.populateMissingRootTitlesList or self.processingFlags.populateMissingRootTitlesDict: - if not rootTitleStr in self.RootNotesExisting.TitlesList: - if not rootTitleStr in self.RootNotesMissing.TitlesList: - self.RootNotesMissing.TitlesList.append(rootTitleStr) - self.RootNotesMissing.ChildTitlesDict[rootTitleStr] = {} - self.RootNotesMissing.ChildNotesDict[rootTitleStr] = {} - if not enNote.Title.Base: - log(enNote.Title) - log(enNote.Base) - assert enNote.Title.Base - childBaseTitleStr = enNote.Title.Base.FullTitle - if childBaseTitleStr in self.RootNotesMissing.ChildTitlesDict[rootTitleStr]: - log_error("Duplicate Child Base Title String. \n%-18s%s\n%-18s%s: %s\n%-18s%s" % ('Root Note Title: ', rootTitleStr, 'Child Note: ', enNote.Guid, childBaseTitleStr, 'Duplicate Note: ', self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitleStr]), crosspost_to_default=False) - if not hasattr(self, 'loggedDuplicateChildNotesWarning'): - log(" > WARNING: Duplicate Child Notes found when processing Root Notes. See error log for more details") - self.loggedDuplicateChildNotesWarning = True - self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitleStr] = enNote.Guid - self.RootNotesMissing.ChildNotesDict[rootTitleStr][enNote.Guid] = enNote - if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: - if not rootTitleStr in self.RootNotes.TitlesList: - self.RootNotes.TitlesList.append(rootTitleStr) - if self.processingFlags.populateRootTitlesDict: - self.RootNotes.TitlesDict[rootTitleStr][enNote.Guid] = enNote.Title.Base - self.RootNotes.NotesDict[rootTitleStr][enNote.Guid] = enNote - if self.processingFlags.populateChildRootTitles or self.processingFlags.populateExistingRootTitlesList or self.processingFlags.populateExistingRootTitlesDict: - if enNote.IsRoot: - rootTitle = enNote.Title - rootTitleStr = generateTOCTitle(rootTitle) - rootGuid = enNote.Guid - if self.processingFlags.populateExistingRootTitlesList or self.processingFlags.populateExistingRootTitlesDict or self.processingFlags.populateMissingRootTitlesList: - if not rootTitleStr in self.RootNotesExisting.TitlesList: - self.RootNotesExisting.TitlesList.append(rootTitleStr) - if self.processingFlags.populateChildRootTitles: - childNotes = ankDB().execute("SELECT * FROM %s WHERE title LIKE '%s:%%' ORDER BY title ASC" % ( - TABLES.EVERNOTE.NOTES, rootTitleStr.replace("'", "''"))) - child_count = 0 - for childDbNote in childNotes: - child_count += 1 - childGuid = childDbNote['guid'] - childEnNote = EvernoteNotePrototype(db_note=childDbNote) - if child_count is 1: - self.RootNotesChildren.TitlesDict[rootGuid] = {} - self.RootNotesChildren.NotesDict[rootGuid] = {} - childBaseTitle = childEnNote.Title.Base - self.RootNotesChildren.TitlesDict[rootGuid][childGuid] = childBaseTitle - self.RootNotesChildren.NotesDict[rootGuid][childGuid] = childEnNote - - def processNotes(self, populateRootTitlesList=True, populateRootTitlesDict=True): - if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: - self.RootNotes = EvernoteNotesCollection() - - self.processingFlags.populateRootTitlesList = populateRootTitlesList - self.processingFlags.populateRootTitlesDict = populateRootTitlesDict - - for guid, enNote in self.Notes: - self.processNote(enNote) - - def processAllChildNotes(self): - self.processingFlags.populateRootTitlesList = True - self.processingFlags.populateRootTitlesDict = True - self.processNotes() - - def populateAllRootTitles(self): - self.getChildNotes() - self.processAllRootTitles() - - def processAllRootTitles(self): - count = 0 - for rootTitle, baseTitles in self.RootNotes.TitlesDict.items(): - count += 1 - baseNoteCount = len(baseTitles) - query = "UPPER(title) = '%s'" % escape_text_sql(rootTitle).upper() - if self.processingFlags.ignoreAutoTOCAsRootTitle: - query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.AUTO_TOC - if self.processingFlags.ignoreOutlineAsRootTitle: - query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.OUTLINE - rootNote = self.getNoteFromDB(query) - if rootNote: - self.RootNotesExisting.TitlesList.append(rootTitle) - else: - self.RootNotesMissing.TitlesList.append(rootTitle) - print_safe(rootNote, ' TOP LEVEL: [%4d::%2d]: [%7s] ' % (count, baseNoteCount, 'is_toc_outline_str')) - # for baseGuid, baseTitle in baseTitles: - # pass - - def getChildNotes(self): - self.addDbQuery("title LIKE '%%:%%'", 'title ASC') - - def getRootNotes(self): - query = "title NOT LIKE '%%:%%'" - if self.processingFlags.ignoreAutoTOCAsRootTitle: - query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.AUTO_TOC - if self.processingFlags.ignoreOutlineAsRootTitle: - query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.OUTLINE - self.addDbQuery(query, 'title ASC') - - def populateAllPotentialRootNotes(self): - self.RootNotesMissing = EvernoteNotesCollection() - processingFlags = EvernoteNoteProcessingFlags(False) - processingFlags.populateMissingRootTitlesList = True - processingFlags.populateMissingRootTitlesDict = True - self.processingFlags = processingFlags - - log(" CHECKING FOR ALL POTENTIAL ROOT TITLES ", 'RootTitles-TOC', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-TOC', timestamp=False) - log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) - self.getChildNotes() - log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-TOC', - timestamp=False) - self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) - - return self.processAllRootNotesMissing() - - def populateAllNonCustomRootNotes(self): - return self.populateAllRootNotesMissing(True, True) - - def populateAllRootNotesMissing(self, ignoreAutoTOCAsRootTitle=False, ignoreOutlineAsRootTitle=False): - processingFlags = EvernoteNoteProcessingFlags(False) - processingFlags.populateMissingRootTitlesList = True - processingFlags.populateMissingRootTitlesDict = True - processingFlags.populateExistingRootTitlesList = True - processingFlags.populateExistingRootTitlesDict = True - processingFlags.ignoreAutoTOCAsRootTitle = ignoreAutoTOCAsRootTitle - processingFlags.ignoreOutlineAsRootTitle = ignoreOutlineAsRootTitle - self.processingFlags = processingFlags - self.RootNotesExisting = EvernoteNotesCollection() - self.RootNotesMissing = EvernoteNotesCollection() - # log(', '.join(self.RootNotesMissing.TitlesList)) - self.getRootNotes() - - log(" CHECKING FOR MISSING ROOT TITLES ", 'RootTitles-Missing', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-Missing', timestamp=False) - log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) - log("Total %d Existing Root Titles" % len(self.RootNotesExisting.TitlesList), 'RootTitles-Missing', - timestamp=False) - self.getChildNotes() - log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-Missing', - timestamp=False) - self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) - - return self.processAllRootNotesMissing() - - def processAllRootNotesMissing(self): - """:rtype : list[EvernoteTOCEntry]""" - DEBUG_HTML = False - count = 0 - count_isolated = 0 - # log (" CREATING TOC's " , 'tocList', clear=True, timestamp=False) - # log ("------------------------------------------------" , 'tocList', timestamp=False) - # if DEBUG_HTML: log('<h1>CREATING TOCs</h1>', 'extra\\logs\\toc-ols\\toc-index.htm', timestamp=False, clear=True, extension='htm') - ols = [] - dbRows = [] - returns = [] - """:type : list[EvernoteTOCEntry]""" - ankDB().execute("DELETE FROM %s WHERE 1 " % TABLES.AUTO_TOC) - ankDB().commit() - # olsz = None - for rootTitleStr in self.RootNotesMissing.TitlesList: - count_child = 0 - childTitlesDictSortedKeys = sorted(self.RootNotesMissing.ChildTitlesDict[rootTitleStr], - key=lambda s: s.lower()) - total_child = len(childTitlesDictSortedKeys) - tags = [] - outline = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( - escape_text_sql(rootTitleStr.upper()), TAGS.OUTLINE)) - currentAutoNote = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( - escape_text_sql(rootTitleStr.upper()), TAGS.AUTO_TOC)) - notebookGuids = {} - childGuid = None - if total_child is 1 and not outline: - count_isolated += 1 - childBaseTitle = childTitlesDictSortedKeys[0] - childGuid = self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitle] - enChildNote = self.RootNotesMissing.ChildNotesDict[rootTitleStr][childGuid] - # tags = enChildNote.Tags - log(" > ISOLATED ROOT TITLE: [%-3d]: %-40s --> %-20s: %s %s" % ( - count_isolated, rootTitleStr + ':', childBaseTitle, childGuid, enChildNote), 'RootTitles-Isolated', - timestamp=False) - else: - count += 1 - log(" [%-3d] %s %s" % (count, rootTitleStr, '(O)' if outline else ' '), 'RootTitles-TOC', - timestamp=False) - # tocList = TOCList(rootTitleStr) - tocHierarchy = TOCHierarchyClass(rootTitleStr) - if outline: - tocHierarchy.Outline = TOCHierarchyClass(note=outline) - tocHierarchy.Outline.parent = tocHierarchy - - for childBaseTitle in childTitlesDictSortedKeys: - count_child += 1 - childGuid = self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitle] - enChildNote = self.RootNotesMissing.ChildNotesDict[rootTitleStr][childGuid] - if count_child == 1: - tags = enChildNote.Tags - else: - tags = [x for x in tags if x in enChildNote.Tags] - if not enChildNote.NotebookGuid in notebookGuids: - notebookGuids[enChildNote.NotebookGuid] = 0 - notebookGuids[enChildNote.NotebookGuid] += 1 - level = enChildNote.Title.Level - # childName = enChildNote.Title.Name - # childTitle = enChildNote.FullTitle - log(" %2d: %d. --> %-60s" % (count_child, level, childBaseTitle), - 'RootTitles-TOC', timestamp=False) - # tocList.generateEntry(childTitle, enChildNote) - tocHierarchy.addNote(enChildNote) - realTitle = ankDB().scalar( - "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, childGuid)) - realTitle = realTitle[0:realTitle.index(':')] - # realTitleUTF8 = realTitle.encode('utf8') - notebookGuid = sorted(notebookGuids.items(), key=itemgetter(1), reverse=True)[0][0] - - real_root_title = generateTOCTitle(realTitle) - - ol = tocHierarchy.GetOrderedList() - tocEntry = EvernoteTOCEntry(real_root_title, ol, ',' + ','.join(tags) + ',', notebookGuid) - returns.append(tocEntry) - dbRows.append(tocEntry.items()) - # ol = realTitleUTF8 - # if olsz is None: olsz = ol - # olsz += ol - # ol = '<OL>\r\n%s</OL>\r\n' - # strr = tocHierarchy.__str__() - if DEBUG_HTML: - ols.append(ol) - olutf8 = ol.encode('utf8') - fn = 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_') + '.htm' - full_path = os.path.join(FOLDERS.LOGS, fn) - if not os.path.exists(os.path.dirname(full_path)): - os.mkdir(os.path.dirname(full_path)) - file_object = open(full_path, 'w') - file_object.write(olutf8) - file_object.close() - - # if DEBUG_HTML: log(ol, 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_'), timestamp=False, clear=True, extension='htm') - # log("Created TOC #%d:\n%s\n\n" % (count, strr), 'tocList', timestamp=False) - if DEBUG_HTML: - ols_html = u'\r\n<BR><BR><HR><BR><BR>\r\n'.join(ols) - fn = 'toc-ols\\toc-index.htm' - file_object = open(os.path.join(FOLDERS.LOGS, fn), 'w') - try: file_object.write(u'<h1>CREATING TOCs</h1>\n\n' + ols_html) - except: - try: file_object.write(u'<h1>CREATING TOCs</h1>\n\n' + ols_html.encode('utf-8')) - except: pass - - file_object.close() - - ankDB().executemany( - "INSERT INTO %s (root_title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?)" % TABLES.AUTO_TOC, - dbRows) - ankDB().commit() - - return returns - - def populateAllRootNotesWithoutTOCOrOutlineDesignation(self): - processingFlags = EvernoteNoteProcessingFlags() - processingFlags.populateRootTitlesList = False - processingFlags.populateRootTitlesDict = False - processingFlags.populateChildRootTitles = True - self.processingFlags = processingFlags - self.getRootNotes() - self.processAllRootNotesWithoutTOCOrOutlineDesignation() - - def processAllRootNotesWithoutTOCOrOutlineDesignation(self): - count = 0 - for rootGuid, childBaseTitleDicts in self.RootNotesChildren.TitlesDict.items(): - rootEnNote = self.Notes[rootGuid] - if len(childBaseTitleDicts.items()) > 0: - is_toc = TAGS.TOC in rootEnNote.Tags - is_outline = TAGS.OUTLINE in rootEnNote.Tags - is_both = is_toc and is_outline - is_none = not is_toc and not is_outline - is_toc_outline_str = "BOTH ???" if is_both else "TOC" if is_toc else "OUTLINE" if is_outline else "N/A" - if is_none: - count += 1 - print_safe(rootEnNote, ' TOP LEVEL: [%3d] %-8s: ' % (count, is_toc_outline_str)) + ################## CLASS Notes ################ + Notes = {} + """:type : dict[str, EvernoteNote.EvernoteNote]""" + RootNotes = EvernoteNotesCollection() + RootNotesChildren = EvernoteNotesCollection() + processingFlags = EvernoteNoteProcessingFlags() + baseQuery = "1" + + def __init__(self, delayProcessing=False): + self.processingFlags.delayProcessing = delayProcessing + self.RootNotes = EvernoteNotesCollection() + + def addNoteSilently(self, enNote): + """:type enNote: EvernoteNote.EvernoteNote""" + assert enNote + self.Notes[enNote.Guid] = enNote + + def addNote(self, enNote): + """:type enNote: EvernoteNote.EvernoteNote""" + assert enNote + self.addNoteSilently(enNote) + if self.processingFlags.delayProcessing: return + self.processNote(enNote) + + def addDBNote(self, dbNote): + """:type dbNote: sqlite.Row""" + enNote = EvernoteNotePrototype(db_note=dbNote) + if not enNote: + log(dbNote) + log(dbNote.keys) + log(dir(dbNote)) + assert enNote + self.addNote(enNote) + + def addDBNotes(self, dbNotes): + """:type dbNotes: list[sqlite.Row]""" + for dbNote in dbNotes: + self.addDBNote(dbNote) + + def addDbQuery(self, sql_query, order=''): + sql_query = "SELECT * FROM %s WHERE (%s) AND (%s) " % (TABLES.EVERNOTE.NOTES, self.baseQuery, sql_query) + if order: sql_query += ' ORDER BY ' + order + dbNotes = ankDB().execute(sql_query) + self.addDBNotes(dbNotes) + + @staticmethod + def getNoteFromDB(query): + """ + + :param query: + :return: + :rtype : sqlite.Row + """ + sql_query = "SELECT * FROM %s WHERE %s " % (TABLES.EVERNOTE.NOTES, query) + dbNote = ankDB().first(sql_query) + if not dbNote: return None + return dbNote + + def getNoteFromDBByGuid(self, guid): + sql_query = "guid = '%s' " % guid + return self.getNoteFromDB(sql_query) + + def getEnNoteFromDBByGuid(self, guid): + return EvernoteNotePrototype(db_note=self.getNoteFromDBByGuid(guid)) + + + # def addChildNoteHierarchically(self, enChildNotes, enChildNote): + # parts = enChildNote.Title.TitleParts + # dict_updated = {} + # dict_building = {parts[len(parts)-1]: enChildNote} + # print_safe(parts) + # for i in range(len(parts), 1, -1): + # dict_building = {parts[i - 1]: dict_building} + # log_dump(dict_building) + # enChildNotes.update(dict_building) + # log_dump(enChildNotes) + # return enChildNotes + + def processNote(self, enNote): + """:type enNote: EvernoteNote.EvernoteNote""" + if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict or self.processingFlags.populateMissingRootTitlesList or self.processingFlags.populateMissingRootTitlesDict: + if enNote.IsChild: + # log([enNote.Title, enNote.Level, enNote.Title.TitleParts, enNote.IsChild]) + rootTitle = enNote.Title.Root + rootTitleStr = generateTOCTitle(rootTitle) + if self.processingFlags.populateMissingRootTitlesList or self.processingFlags.populateMissingRootTitlesDict: + if not rootTitleStr in self.RootNotesExisting.TitlesList: + if not rootTitleStr in self.RootNotesMissing.TitlesList: + self.RootNotesMissing.TitlesList.append(rootTitleStr) + self.RootNotesMissing.ChildTitlesDict[rootTitleStr] = {} + self.RootNotesMissing.ChildNotesDict[rootTitleStr] = {} + if not enNote.Title.Base: + log(enNote.Title) + log(enNote.Base) + assert enNote.Title.Base + childBaseTitleStr = enNote.Title.Base.FullTitle + if childBaseTitleStr in self.RootNotesMissing.ChildTitlesDict[rootTitleStr]: + log_error("Duplicate Child Base Title String. \n%-18s%s\n%-18s%s: %s\n%-18s%s" % ( + 'Root Note Title: ', rootTitleStr, 'Child Note: ', enNote.Guid, childBaseTitleStr, + 'Duplicate Note: ', self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitleStr]), + crosspost_to_default=False) + if not hasattr(self, 'loggedDuplicateChildNotesWarning'): + log( + " > WARNING: Duplicate Child Notes found when processing Root Notes. See error log for more details") + self.loggedDuplicateChildNotesWarning = True + self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitleStr] = enNote.Guid + self.RootNotesMissing.ChildNotesDict[rootTitleStr][enNote.Guid] = enNote + if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: + if not rootTitleStr in self.RootNotes.TitlesList: + self.RootNotes.TitlesList.append(rootTitleStr) + if self.processingFlags.populateRootTitlesDict: + self.RootNotes.TitlesDict[rootTitleStr][enNote.Guid] = enNote.Title.Base + self.RootNotes.NotesDict[rootTitleStr][enNote.Guid] = enNote + if self.processingFlags.populateChildRootTitles or self.processingFlags.populateExistingRootTitlesList or self.processingFlags.populateExistingRootTitlesDict: + if enNote.IsRoot: + rootTitle = enNote.Title + rootTitleStr = generateTOCTitle(rootTitle) + rootGuid = enNote.Guid + if self.processingFlags.populateExistingRootTitlesList or self.processingFlags.populateExistingRootTitlesDict or self.processingFlags.populateMissingRootTitlesList: + if not rootTitleStr in self.RootNotesExisting.TitlesList: + self.RootNotesExisting.TitlesList.append(rootTitleStr) + if self.processingFlags.populateChildRootTitles: + childNotes = ankDB().execute("SELECT * FROM %s WHERE title LIKE '%s:%%' ORDER BY title ASC" % ( + TABLES.EVERNOTE.NOTES, rootTitleStr.replace("'", "''"))) + child_count = 0 + for childDbNote in childNotes: + child_count += 1 + childGuid = childDbNote['guid'] + childEnNote = EvernoteNotePrototype(db_note=childDbNote) + if child_count is 1: + self.RootNotesChildren.TitlesDict[rootGuid] = {} + self.RootNotesChildren.NotesDict[rootGuid] = {} + childBaseTitle = childEnNote.Title.Base + self.RootNotesChildren.TitlesDict[rootGuid][childGuid] = childBaseTitle + self.RootNotesChildren.NotesDict[rootGuid][childGuid] = childEnNote + + def processNotes(self, populateRootTitlesList=True, populateRootTitlesDict=True): + if self.processingFlags.populateRootTitlesList or self.processingFlags.populateRootTitlesDict: + self.RootNotes = EvernoteNotesCollection() + + self.processingFlags.populateRootTitlesList = populateRootTitlesList + self.processingFlags.populateRootTitlesDict = populateRootTitlesDict + + for guid, enNote in self.Notes: + self.processNote(enNote) + + def processAllChildNotes(self): + self.processingFlags.populateRootTitlesList = True + self.processingFlags.populateRootTitlesDict = True + self.processNotes() + + def populateAllRootTitles(self): + self.getChildNotes() + self.processAllRootTitles() + + def processAllRootTitles(self): + count = 0 + for rootTitle, baseTitles in self.RootNotes.TitlesDict.items(): + count += 1 + baseNoteCount = len(baseTitles) + query = "UPPER(title) = '%s'" % escape_text_sql(rootTitle).upper() + if self.processingFlags.ignoreAutoTOCAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.AUTO_TOC + if self.processingFlags.ignoreOutlineAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.OUTLINE + rootNote = self.getNoteFromDB(query) + if rootNote: + self.RootNotesExisting.TitlesList.append(rootTitle) + else: + self.RootNotesMissing.TitlesList.append(rootTitle) + print_safe(rootNote, ' TOP LEVEL: [%4d::%2d]: [%7s] ' % (count, baseNoteCount, 'is_toc_outline_str')) + # for baseGuid, baseTitle in baseTitles: + # pass + + def getChildNotes(self): + self.addDbQuery("title LIKE '%%:%%'", 'title ASC') + + def getRootNotes(self): + query = "title NOT LIKE '%%:%%'" + if self.processingFlags.ignoreAutoTOCAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.AUTO_TOC + if self.processingFlags.ignoreOutlineAsRootTitle: + query += " AND tagNames NOT LIKE '%%,%s,%%'" % TAGS.OUTLINE + self.addDbQuery(query, 'title ASC') + + def populateAllPotentialRootNotes(self): + self.RootNotesMissing = EvernoteNotesCollection() + processingFlags = EvernoteNoteProcessingFlags(False) + processingFlags.populateMissingRootTitlesList = True + processingFlags.populateMissingRootTitlesDict = True + self.processingFlags = processingFlags + + log(" CHECKING FOR ALL POTENTIAL ROOT TITLES ", 'RootTitles-TOC', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-TOC', timestamp=False) + log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) + self.getChildNotes() + log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-TOC', + timestamp=False) + self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) + + return self.processAllRootNotesMissing() + + def populateAllNonCustomRootNotes(self): + return self.populateAllRootNotesMissing(True, True) + + def populateAllRootNotesMissing(self, ignoreAutoTOCAsRootTitle=False, ignoreOutlineAsRootTitle=False): + processingFlags = EvernoteNoteProcessingFlags(False) + processingFlags.populateMissingRootTitlesList = True + processingFlags.populateMissingRootTitlesDict = True + processingFlags.populateExistingRootTitlesList = True + processingFlags.populateExistingRootTitlesDict = True + processingFlags.ignoreAutoTOCAsRootTitle = ignoreAutoTOCAsRootTitle + processingFlags.ignoreOutlineAsRootTitle = ignoreOutlineAsRootTitle + self.processingFlags = processingFlags + self.RootNotesExisting = EvernoteNotesCollection() + self.RootNotesMissing = EvernoteNotesCollection() + # log(', '.join(self.RootNotesMissing.TitlesList)) + self.getRootNotes() + + log(" CHECKING FOR MISSING ROOT TITLES ", 'RootTitles-Missing', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-Missing', timestamp=False) + log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) + log("Total %d Existing Root Titles" % len(self.RootNotesExisting.TitlesList), 'RootTitles-Missing', + timestamp=False) + self.getChildNotes() + log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-Missing', + timestamp=False) + self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) + + return self.processAllRootNotesMissing() + + def processAllRootNotesMissing(self): + """:rtype : list[EvernoteTOCEntry]""" + DEBUG_HTML = False + count = 0 + count_isolated = 0 + # log (" CREATING TOC's " , 'tocList', clear=True, timestamp=False) + # log ("------------------------------------------------" , 'tocList', timestamp=False) + # if DEBUG_HTML: log('<h1>CREATING TOCs</h1>', 'extra\\logs\\toc-ols\\toc-index.htm', timestamp=False, clear=True, extension='htm') + ols = [] + dbRows = [] + returns = [] + """:type : list[EvernoteTOCEntry]""" + ankDB().execute("DELETE FROM %s WHERE 1 " % TABLES.AUTO_TOC) + ankDB().commit() + # olsz = None + for rootTitleStr in self.RootNotesMissing.TitlesList: + count_child = 0 + childTitlesDictSortedKeys = sorted(self.RootNotesMissing.ChildTitlesDict[rootTitleStr], + key=lambda s: s.lower()) + total_child = len(childTitlesDictSortedKeys) + tags = [] + outline = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( + escape_text_sql(rootTitleStr.upper()), TAGS.OUTLINE)) + currentAutoNote = self.getNoteFromDB("UPPER(title) = '%s' AND tagNames LIKE '%%,%s,%%'" % ( + escape_text_sql(rootTitleStr.upper()), TAGS.AUTO_TOC)) + notebookGuids = {} + childGuid = None + if total_child is 1 and not outline: + count_isolated += 1 + childBaseTitle = childTitlesDictSortedKeys[0] + childGuid = self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitle] + enChildNote = self.RootNotesMissing.ChildNotesDict[rootTitleStr][childGuid] + # tags = enChildNote.Tags + log(" > ISOLATED ROOT TITLE: [%-3d]: %-40s --> %-20s: %s %s" % ( + count_isolated, rootTitleStr + ':', childBaseTitle, childGuid, enChildNote), 'RootTitles-Isolated', + timestamp=False) + else: + count += 1 + log(" [%-3d] %s %s" % (count, rootTitleStr, '(O)' if outline else ' '), 'RootTitles-TOC', + timestamp=False) + # tocList = TOCList(rootTitleStr) + tocHierarchy = TOCHierarchyClass(rootTitleStr) + if outline: + tocHierarchy.Outline = TOCHierarchyClass(note=outline) + tocHierarchy.Outline.parent = tocHierarchy + + for childBaseTitle in childTitlesDictSortedKeys: + count_child += 1 + childGuid = self.RootNotesMissing.ChildTitlesDict[rootTitleStr][childBaseTitle] + enChildNote = self.RootNotesMissing.ChildNotesDict[rootTitleStr][childGuid] + if count_child == 1: + tags = enChildNote.Tags + else: + tags = [x for x in tags if x in enChildNote.Tags] + if not enChildNote.NotebookGuid in notebookGuids: + notebookGuids[enChildNote.NotebookGuid] = 0 + notebookGuids[enChildNote.NotebookGuid] += 1 + level = enChildNote.Title.Level + # childName = enChildNote.Title.Name + # childTitle = enChildNote.FullTitle + log(" %2d: %d. --> %-60s" % (count_child, level, childBaseTitle), + 'RootTitles-TOC', timestamp=False) + # tocList.generateEntry(childTitle, enChildNote) + tocHierarchy.addNote(enChildNote) + realTitle = ankDB().scalar( + "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, childGuid)) + realTitle = realTitle[0:realTitle.index(':')] + # realTitleUTF8 = realTitle.encode('utf8') + notebookGuid = sorted(notebookGuids.items(), key=itemgetter(1), reverse=True)[0][0] + + real_root_title = generateTOCTitle(realTitle) + + ol = tocHierarchy.GetOrderedList() + tocEntry = EvernoteTOCEntry(real_root_title, ol, ',' + ','.join(tags) + ',', notebookGuid) + returns.append(tocEntry) + dbRows.append(tocEntry.items()) + # ol = realTitleUTF8 + # if olsz is None: olsz = ol + # olsz += ol + # ol = '<OL>\r\n%s</OL>\r\n' + # strr = tocHierarchy.__str__() + if DEBUG_HTML: + ols.append(ol) + olutf8 = ol.encode('utf8') + fn = 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_') + '.htm' + full_path = os.path.join(FOLDERS.LOGS, fn) + if not os.path.exists(os.path.dirname(full_path)): + os.mkdir(os.path.dirname(full_path)) + file_object = open(full_path, 'w') + file_object.write(olutf8) + file_object.close() + + # if DEBUG_HTML: log(ol, 'toc-ols\\toc-' + str(count) + '-' + rootTitleStr.replace('\\', '_'), timestamp=False, clear=True, extension='htm') + # log("Created TOC #%d:\n%s\n\n" % (count, strr), 'tocList', timestamp=False) + if DEBUG_HTML: + ols_html = u'\r\n<BR><BR><HR><BR><BR>\r\n'.join(ols) + fn = 'toc-ols\\toc-index.htm' + file_object = open(os.path.join(FOLDERS.LOGS, fn), 'w') + try: + file_object.write(u'<h1>CREATING TOCs</h1>\n\n' + ols_html) + except: + try: + file_object.write(u'<h1>CREATING TOCs</h1>\n\n' + ols_html.encode('utf-8')) + except: + pass + + file_object.close() + + ankDB().executemany( + "INSERT INTO %s (root_title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?)" % TABLES.AUTO_TOC, + dbRows) + ankDB().commit() + + return returns + + def populateAllRootNotesWithoutTOCOrOutlineDesignation(self): + processingFlags = EvernoteNoteProcessingFlags() + processingFlags.populateRootTitlesList = False + processingFlags.populateRootTitlesDict = False + processingFlags.populateChildRootTitles = True + self.processingFlags = processingFlags + self.getRootNotes() + self.processAllRootNotesWithoutTOCOrOutlineDesignation() + + def processAllRootNotesWithoutTOCOrOutlineDesignation(self): + count = 0 + for rootGuid, childBaseTitleDicts in self.RootNotesChildren.TitlesDict.items(): + rootEnNote = self.Notes[rootGuid] + if len(childBaseTitleDicts.items()) > 0: + is_toc = TAGS.TOC in rootEnNote.Tags + is_outline = TAGS.OUTLINE in rootEnNote.Tags + is_both = is_toc and is_outline + is_none = not is_toc and not is_outline + is_toc_outline_str = "BOTH ???" if is_both else "TOC" if is_toc else "OUTLINE" if is_outline else "N/A" + if is_none: + count += 1 + print_safe(rootEnNote, ' TOP LEVEL: [%3d] %-8s: ' % (count, is_toc_outline_str)) diff --git a/anknotes/__main__.py b/anknotes/__main__.py index 8cd4077..19e7558 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -1,13 +1,17 @@ # -*- coding: utf-8 -*- ### Python Imports import os -import re, sre_constants +import re +import sre_constants + try: - from pysqlite2 import dbapi2 as sqlite - is_pysqlite = True + from pysqlite2 import dbapi2 as sqlite + + is_pysqlite = True except ImportError: - from sqlite3 import dbapi2 as sqlite - is_pysqlite = False + from sqlite3 import dbapi2 as sqlite + + is_pysqlite = False ### Anknotes Shared Imports from anknotes.shared import * @@ -29,205 +33,225 @@ # from aqt.qt.qt import MatchFlag def import_timer_toggle(): - title = "&Enable Auto Import On Profile Load" - doAutoImport = mw.col.conf.get( - SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False) - if not doAutoImport: return - lastImport = mw.col.conf.get(SETTINGS.EVERNOTE.LAST_IMPORT, None) - importDelay = 0 - if lastImport: - td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) - minimum = timedelta(seconds=max(EVERNOTE.IMPORT.INTERVAL, 20*60)) - if td < minimum: - importDelay = (minimum - td).total_seconds() * 1000 - if importDelay is 0: - menu.import_from_evernote() - else: - m, s = divmod(importDelay / 1000, 60) - log("> Starting Auto Import, Triggered by Profile Load, in %d:%02d min" % (m, s)) - mw.progress.timer(importDelay, menu.import_from_evernote, False) + title = "&Enable Auto Import On Profile Load" + doAutoImport = mw.col.conf.get( + SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False) + if not doAutoImport: return + lastImport = mw.col.conf.get(SETTINGS.EVERNOTE.LAST_IMPORT, None) + importDelay = 0 + if lastImport: + td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) + minimum = timedelta(seconds=max(EVERNOTE.IMPORT.INTERVAL, 20 * 60)) + if td < minimum: + importDelay = (minimum - td).total_seconds() * 1000 + if importDelay is 0: + menu.import_from_evernote() + else: + m, s = divmod(importDelay / 1000, 60) + log("> Starting Auto Import, Triggered by Profile Load, in %d:%02d min" % (m, s)) + mw.progress.timer(importDelay, menu.import_from_evernote, False) def _findEdited((val, args)): - try: days = int(val) - except ValueError: return - return "c.mod > %d" % (time.time() - days * 86400) + try: + days = int(val) + except ValueError: + return + return "c.mod > %d" % (time.time() - days * 86400) + def _findHierarchy((val, args)): - if val == 'root': - return "n.sfld NOT LIKE '%:%' AND ank.title LIKE '%' || n.sfld || ':%'" - if val == 'sub': - return 'n.sfld like "%:%"' - if val == 'child': - return "UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) IN (SELECT UPPER(title) FROM %s WHERE title NOT LIKE '%%:%%' AND tagNames LIKE '%%,%s,%%') " % (TABLES.EVERNOTE.NOTES, TAGS.TOC) - if val == 'orphan': - return "n.sfld LIKE '%%:%%' AND UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) NOT IN (SELECT UPPER(title) FROM %s WHERE title NOT LIKE '%%:%%' AND tagNames LIKE '%%,%s,%%') " % (TABLES.EVERNOTE.NOTES, TAGS.TOC) - # showInfo(val) + if val == 'root': + return "n.sfld NOT LIKE '%:%' AND ank.title LIKE '%' || n.sfld || ':%'" + if val == 'sub': + return 'n.sfld like "%:%"' + if val == 'child': + return "UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) IN (SELECT UPPER(title) FROM %s WHERE title NOT LIKE '%%:%%' AND tagNames LIKE '%%,%s,%%') " % ( + TABLES.EVERNOTE.NOTES, TAGS.TOC) + if val == 'orphan': + return "n.sfld LIKE '%%:%%' AND UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) NOT IN (SELECT UPPER(title) FROM %s WHERE title NOT LIKE '%%:%%' AND tagNames LIKE '%%,%s,%%') " % ( + TABLES.EVERNOTE.NOTES, TAGS.TOC) + # showInfo(val) + class CallbackItem(QTreeWidgetItem): - def __init__(self, root, name, onclick, oncollapse=None): - QTreeWidgetItem.__init__(self, root, [name]) - self.onclick = onclick - self.oncollapse = oncollapse + def __init__(self, root, name, onclick, oncollapse=None): + QTreeWidgetItem.__init__(self, root, [name]) + self.onclick = onclick + self.oncollapse = oncollapse + def anknotes_browser_tagtree_wrap(self, root, _old): - """ - - :param root: - :type root : QTreeWidget - :param _old: - :return: - """ - tags = [ - (_("Edited This Week"), "view-pim-calendar.png", "edited:7"), - (_("Root Notes"), "hierarchy:root"), - (_("Sub Notes"), "hierarchy:sub"), - (_("Child Notes"), "hierarchy:child"), - (_("Orphan Notes"), "hierarchy:orphan") - ] - # tags.reverse() - root = _old(self, root) - indices = root.findItems(_("Added Today"), Qt.MatchFixedString) - index = (root.indexOfTopLevelItem(indices[0]) + 1) if indices else 3 - from anknotes.graphics import icoEvernoteWeb - for name, icon, cmd in tags[:1]: - onclick = lambda c=cmd: self.setFilter(c) - widgetItem = QTreeWidgetItem([name]) - widgetItem.onclick = onclick - widgetItem.setIcon(0, QIcon(":/icons/" + icon)) - root.insertTopLevelItem(index, widgetItem) - root = self.CallbackItem(root, _("Anknotes Hierarchy"), None) - root.setExpanded(True) - root.setIcon(0, icoEvernoteWeb) - for name, cmd in tags[1:]: - item = self.CallbackItem(root, name,lambda c=cmd: self.setFilter(c)) - item.setIcon(0, icoEvernoteWeb) - return root + """ + + :param root: + :type root : QTreeWidget + :param _old: + :return: + """ + tags = [ + (_("Edited This Week"), "view-pim-calendar.png", "edited:7"), + (_("Root Notes"), "hierarchy:root"), + (_("Sub Notes"), "hierarchy:sub"), + (_("Child Notes"), "hierarchy:child"), + (_("Orphan Notes"), "hierarchy:orphan") + ] + # tags.reverse() + root = _old(self, root) + indices = root.findItems(_("Added Today"), Qt.MatchFixedString) + index = (root.indexOfTopLevelItem(indices[0]) + 1) if indices else 3 + from anknotes.graphics import icoEvernoteWeb + + for name, icon, cmd in tags[:1]: + onclick = lambda c=cmd: self.setFilter(c) + widgetItem = QTreeWidgetItem([name]) + widgetItem.onclick = onclick + widgetItem.setIcon(0, QIcon(":/icons/" + icon)) + root.insertTopLevelItem(index, widgetItem) + root = self.CallbackItem(root, _("Anknotes Hierarchy"), None) + root.setExpanded(True) + root.setIcon(0, icoEvernoteWeb) + for name, cmd in tags[1:]: + item = self.CallbackItem(root, name, lambda c=cmd: self.setFilter(c)) + item.setIcon(0, icoEvernoteWeb) + return root + def _findField(self, field, val, _old=None): - def doCheck(self, field, val): - field = field.lower() - val = val.replace("*", "%") - # find models that have that field - mods = {} - for m in self.col.models.all(): - for f in m['flds']: - if f['name'].lower() == field: - mods[str(m['id'])] = (m, f['ord']) - - if not mods: - # nothing has that field - return - # gather nids - - regex = re.escape(val).replace("\\_", ".").replace("\\%", ".*") - sql = """ + def doCheck(self, field, val): + field = field.lower() + val = val.replace("*", "%") + # find models that have that field + mods = {} + for m in self.col.models.all(): + for f in m['flds']: + if f['name'].lower() == field: + mods[str(m['id'])] = (m, f['ord']) + + if not mods: + # nothing has that field + return + # gather nids + + regex = re.escape(val).replace("\\_", ".").replace("\\%", ".*") + sql = """ select id, mid, flds from notes where mid in %s and flds like ? escape '\\'""" % ( - ids2str(mods.keys())) - nids = [] - for (id,mid,flds) in self.col.db.execute(sql, "%"+val+"%"): - flds = splitFields(flds) - ord = mods[str(mid)][1] - strg = flds[ord] - try: - if re.search("(?si)^"+regex+"$", strg): nids.append(id) - except sre_constants.error: return - if not nids: return "0" - return "n.id in %s" % ids2str(nids) - # val = doCheck(field, val) - vtest = doCheck(self, field, val) - log("FindField for %s: %s: Total %d matches " %(field, str(val), len(vtest.split(','))), 'sql-finder') - return vtest - # return _old(self, field, val) + ids2str(mods.keys())) + nids = [] + for (id, mid, flds) in self.col.db.execute(sql, "%" + val + "%"): + flds = splitFields(flds) + ord = mods[str(mid)][1] + strg = flds[ord] + try: + if re.search("(?si)^" + regex + "$", strg): nids.append(id) + except sre_constants.error: + return + if not nids: return "0" + return "n.id in %s" % ids2str(nids) + + # val = doCheck(field, val) + vtest = doCheck(self, field, val) + log("FindField for %s: %s: Total %d matches " % (field, str(val), len(vtest.split(','))), 'sql-finder') + return vtest + + +# return _old(self, field, val) def anknotes_finder_findCards_wrap(self, query, order=False, _old=None): - log("Searching with text " + query , 'sql-finder') - "Return a list of card ids for QUERY." - tokens = self._tokenize(query) - preds, args = self._where(tokens) - log("Tokens: %-20s Preds: %-20s Args: %-20s " % (str(tokens), str(preds), str(args)) , 'sql-finder') - if preds is None: - return [] - order, rev = self._order(order) - sql = self._query(preds, order) - # showInfo(sql) - try: - res = self.col.db.list(sql, *args) - except Exception as ex: - # invalid grouping - log("Error with query %s: %s.\n%s" % (query, str(ex), [sql, args]) , 'sql-finder') - return [] - if rev: - res.reverse() - return res - return _old(self, query, order) + log("Searching with text " + query, 'sql-finder') + "Return a list of card ids for QUERY." + tokens = self._tokenize(query) + preds, args = self._where(tokens) + log("Tokens: %-20s Preds: %-20s Args: %-20s " % (str(tokens), str(preds), str(args)), 'sql-finder') + if preds is None: + return [] + order, rev = self._order(order) + sql = self._query(preds, order) + # showInfo(sql) + try: + res = self.col.db.list(sql, *args) + except Exception as ex: + # invalid grouping + log("Error with query %s: %s.\n%s" % (query, str(ex), [sql, args]), 'sql-finder') + return [] + if rev: + res.reverse() + return res + return _old(self, query, order) + def anknotes_finder_query_wrap(self, preds=None, order=None, _old=None): - if _old is None or not isinstance(self, Finder): - log_dump([self, preds, order], 'Finder Query Wrap Error', 'finder') - return - sql = _old(self, preds, order) - if "ank." in preds: - sql = sql.replace("select c.id", "select distinct c.id").replace("from cards c", "from cards c, %s ank" % TABLES.EVERNOTE.NOTES) - log('Custom anknotes finder SELECT query: \n%s' % sql, 'sql-finder') - elif TABLES.EVERNOTE.NOTES in preds: - log('Custom anknotes finder alternate query: \n%s' % sql, 'sql-finder') - else: - log("Anki finder query: %s" % sql, 'sql-finder') - return sql + if _old is None or not isinstance(self, Finder): + log_dump([self, preds, order], 'Finder Query Wrap Error', 'finder') + return + sql = _old(self, preds, order) + if "ank." in preds: + sql = sql.replace("select c.id", "select distinct c.id").replace("from cards c", + "from cards c, %s ank" % TABLES.EVERNOTE.NOTES) + log('Custom anknotes finder SELECT query: \n%s' % sql, 'sql-finder') + elif TABLES.EVERNOTE.NOTES in preds: + log('Custom anknotes finder alternate query: \n%s' % sql, 'sql-finder') + else: + log("Anki finder query: %s" % sql, 'sql-finder') + return sql + + def anknotes_search_hook(search): - if not 'edited' in search: - search['edited'] = _findEdited - if not 'hierarchy' in search: - search['hierarchy'] = _findHierarchy + if not 'edited' in search: + search['edited'] = _findEdited + if not 'hierarchy' in search: + search['hierarchy'] = _findHierarchy + def reset_everything(): - ankDB().InitSeeAlso(True) - menu.resync_with_local_db() - menu.see_also([1, 2, 5, 6, 7]) + ankDB().InitSeeAlso(True) + menu.resync_with_local_db() + menu.see_also([1, 2, 5, 6, 7]) + def anknotes_profile_loaded(): - if not os.path.exists(os.path.dirname(FILES.USER.LAST_PROFILE_LOCATION)): os.makedirs(os.path.dirname(FILES.USER.LAST_PROFILE_LOCATION)) - with open(FILES.USER.LAST_PROFILE_LOCATION, 'w+') as myFile: - print>> myFile, mw.pm.name - menu.anknotes_load_menu_settings() - if EVERNOTE.UPLOAD.VALIDATION.ENABLED and EVERNOTE.UPLOAD.VALIDATION.AUTOMATED: - menu.upload_validated_notes(True) - import_timer_toggle() - - if ANKNOTES.DEVELOPER_MODE.AUTOMATED: - ''' - For testing purposes only! - Add a function here and it will automatically run on profile load - You must create the files 'anknotes.developer' and 'anknotes.developer.automate' in the /extra/dev/ folder - ''' - # reset_everything() - menu.see_also([7]) - - # menu.resync_with_local_db() - # menu.see_also([1, 2, 5, 6, 7]) - # menu.see_also([6, 7]) - # menu.resync_with_local_db() - # menu.see_also() - # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) - # menu.see_also(3) - # menu.see_also(4) - # mw.progress.timer(20000, lambda : menu.find_deleted_notes(True), False) - # menu.see_also([3,4]) - # menu.resync_with_local_db() - pass + if not os.path.exists(os.path.dirname(FILES.USER.LAST_PROFILE_LOCATION)): os.makedirs( + os.path.dirname(FILES.USER.LAST_PROFILE_LOCATION)) + with open(FILES.USER.LAST_PROFILE_LOCATION, 'w+') as myFile: + print >> myFile, mw.pm.name + menu.anknotes_load_menu_settings() + if EVERNOTE.UPLOAD.VALIDATION.ENABLED and EVERNOTE.UPLOAD.VALIDATION.AUTOMATED: + menu.upload_validated_notes(True) + import_timer_toggle() -def anknotes_onload(): + if ANKNOTES.DEVELOPER_MODE.AUTOMATED: + ''' + For testing purposes only! + Add a function here and it will automatically run on profile load + You must create the files 'anknotes.developer' and 'anknotes.developer.automate' in the /extra/dev/ folder + ''' + # reset_everything() + menu.see_also([7]) + + # menu.resync_with_local_db() + # menu.see_also([1, 2, 5, 6, 7]) + # menu.see_also([6, 7]) + # menu.resync_with_local_db() + # menu.see_also() + # menu.import_from_evernote(auto_page_callback=lambda: lambda: menu.see_also(3)) + # menu.see_also(3) + # menu.see_also(4) + # mw.progress.timer(20000, lambda : menu.find_deleted_notes(True), False) + # menu.see_also([3,4]) + # menu.resync_with_local_db() + pass - addHook("profileLoaded", anknotes_profile_loaded) - addHook("search", anknotes_search_hook) - Finder._query = wrap(Finder._query, anknotes_finder_query_wrap, "around") - Finder._findField = wrap(Finder._findField, _findField, "around" ) - Finder.findCards = wrap(Finder.findCards, anknotes_finder_findCards_wrap, "around") - browser.Browser._systemTagTree = wrap(browser.Browser._systemTagTree, anknotes_browser_tagtree_wrap, "around") - menu.anknotes_setup_menu() - Preferences.setupOptions = wrap(Preferences.setupOptions, settings.setup_evernote) + +def anknotes_onload(): + addHook("profileLoaded", anknotes_profile_loaded) + addHook("search", anknotes_search_hook) + Finder._query = wrap(Finder._query, anknotes_finder_query_wrap, "around") + Finder._findField = wrap(Finder._findField, _findField, "around") + Finder.findCards = wrap(Finder.findCards, anknotes_finder_findCards_wrap, "around") + browser.Browser._systemTagTree = wrap(browser.Browser._systemTagTree, anknotes_browser_tagtree_wrap, "around") + menu.anknotes_setup_menu() + Preferences.setupOptions = wrap(Preferences.setupOptions, settings.setup_evernote) anknotes_onload() diff --git a/anknotes/_re.py b/anknotes/_re.py index a7147bf..151ddc9 100644 --- a/anknotes/_re.py +++ b/anknotes/_re.py @@ -2,276 +2,276 @@ def compile(pattern, flags=0): - """Compile a regular expression pattern, returning a pattern object. + """Compile a regular expression pattern, returning a pattern object. - :type pattern: bytes | unicode - :type flags: int - :rtype: __Regex - """ - pass + :type pattern: bytes | unicode + :type flags: int + :rtype: __Regex + """ + pass def search(pattern, string, flags=0): - """Scan through string looking for a match, and return a corresponding - match instance. Return None if no position in the string matches. + """Scan through string looking for a match, and return a corresponding + match instance. Return None if no position in the string matches. - :type pattern: bytes | unicode | __Regex - :type string: T <= bytes | unicode - :type flags: int - :rtype: __Match[T] | None - """ - pass + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: __Match[T] | None + """ + pass def match(pattern, string, flags=0): - """Matches zero or more characters at the beginning of the string. + """Matches zero or more characters at the beginning of the string. - :type pattern: bytes | unicode | __Regex - :type string: T <= bytes | unicode - :type flags: int - :rtype: __Match[T] | None - """ - pass + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: __Match[T] | None + """ + pass def split(pattern, string, maxsplit=0, flags=0): - """Split string by the occurrences of pattern. + """Split string by the occurrences of pattern. - :type pattern: bytes | unicode | __Regex - :type string: T <= bytes | unicode - :type maxsplit: int - :type flags: int - :rtype: list[T] - """ - pass + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type maxsplit: int + :type flags: int + :rtype: list[T] + """ + pass def findall(pattern, string, flags=0): - """Return a list of all non-overlapping matches of pattern in string. + """Return a list of all non-overlapping matches of pattern in string. - :type pattern: bytes | unicode | __Regex - :type string: T <= bytes | unicode - :type flags: int - :rtype: list[T] - """ - pass + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: list[T] + """ + pass def finditer(pattern, string, flags=0): - """Return an iterator over all non-overlapping matches for the pattern in - string. For each match, the iterator returns a match object. + """Return an iterator over all non-overlapping matches for the pattern in + string. For each match, the iterator returns a match object. - :type pattern: bytes | unicode | __Regex - :type string: T <= bytes | unicode - :type flags: int - :rtype: collections.Iterable[__Match[T]] - """ - pass + :type pattern: bytes | unicode | __Regex + :type string: T <= bytes | unicode + :type flags: int + :rtype: collections.Iterable[__Match[T]] + """ + pass def sub(pattern, repl, string, count=0, flags=0): - """Return the string obtained by replacing the leftmost non-overlapping - occurrences of pattern in string by the replacement repl. + """Return the string obtained by replacing the leftmost non-overlapping + occurrences of pattern in string by the replacement repl. - :type pattern: bytes | unicode | __Regex - :type repl: bytes | unicode | collections.Callable - :type string: T <= bytes | unicode - :type count: int - :type flags: int - :rtype: T - """ - pass + :type pattern: bytes | unicode | __Regex + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :type flags: int + :rtype: T + """ + pass def subn(pattern, repl, string, count=0, flags=0): - """Return the tuple (new_string, number_of_subs_made) found by replacing - the leftmost non-overlapping occurrences of pattern with the - replacement repl. + """Return the tuple (new_string, number_of_subs_made) found by replacing + the leftmost non-overlapping occurrences of pattern with the + replacement repl. - :type pattern: bytes | unicode | __Regex - :type repl: bytes | unicode | collections.Callable - :type string: T <= bytes | unicode - :type count: int - :type flags: int - :rtype: (T, int) - """ - pass + :type pattern: bytes | unicode | __Regex + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :type flags: int + :rtype: (T, int) + """ + pass def escape(string): - """Escape all the characters in pattern except ASCII letters and numbers. + """Escape all the characters in pattern except ASCII letters and numbers. - :type string: T <= bytes | unicode - :type: T - """ - pass + :type string: T <= bytes | unicode + :type: T + """ + pass class __Regex(object): - """Mock class for a regular expression pattern object.""" - - def __init__(self, flags, groups, groupindex, pattern): - """Create a new pattern object. - - :type flags: int - :type groups: int - :type groupindex: dict[bytes | unicode, int] - :type pattern: bytes | unicode - """ - self.flags = flags - self.groups = groups - self.groupindex = groupindex - self.pattern = pattern - - def search(self, string, pos=0, endpos=-1): - """Scan through string looking for a match, and return a corresponding - match instance. Return None if no position in the string matches. - - :type string: T <= bytes | unicode - :type pos: int - :type endpos: int - :rtype: __Match[T] | None - """ - pass - - def match(self, string, pos=0, endpos=-1): - """Matches zero | more characters at the beginning of the string. - - :type string: T <= bytes | unicode - :type pos: int - :type endpos: int - :rtype: __Match[T] | None - """ - pass - - def split(self, string, maxsplit=0): - """Split string by the occurrences of pattern. - - :type string: T <= bytes | unicode - :type maxsplit: int - :rtype: list[T] - """ - pass - - def findall(self, string, pos=0, endpos=-1): - """Return a list of all non-overlapping matches of pattern in string. - - :type string: T <= bytes | unicode - :type pos: int - :type endpos: int - :rtype: list[T] - """ - pass - - def finditer(self, string, pos=0, endpos=-1): - """Return an iterator over all non-overlapping matches for the - pattern in string. For each match, the iterator returns a - match object. - - :type string: T <= bytes | unicode - :type pos: int - :type endpos: int - :rtype: collections.Iterable[__Match[T]] - """ - pass - - def sub(self, repl, string, count=0): - """Return the string obtained by replacing the leftmost non-overlapping - occurrences of pattern in string by the replacement repl. - - :type repl: bytes | unicode | collections.Callable - :type string: T <= bytes | unicode - :type count: int - :rtype: T - """ - pass - - def subn(self, repl, string, count=0): - """Return the tuple (new_string, number_of_subs_made) found by replacing - the leftmost non-overlapping occurrences of pattern with the - replacement repl. - - :type repl: bytes | unicode | collections.Callable - :type string: T <= bytes | unicode - :type count: int - :rtype: (T, int) - """ - pass + """Mock class for a regular expression pattern object.""" + + def __init__(self, flags, groups, groupindex, pattern): + """Create a new pattern object. + + :type flags: int + :type groups: int + :type groupindex: dict[bytes | unicode, int] + :type pattern: bytes | unicode + """ + self.flags = flags + self.groups = groups + self.groupindex = groupindex + self.pattern = pattern + + def search(self, string, pos=0, endpos=-1): + """Scan through string looking for a match, and return a corresponding + match instance. Return None if no position in the string matches. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: __Match[T] | None + """ + pass + + def match(self, string, pos=0, endpos=-1): + """Matches zero | more characters at the beginning of the string. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: __Match[T] | None + """ + pass + + def split(self, string, maxsplit=0): + """Split string by the occurrences of pattern. + + :type string: T <= bytes | unicode + :type maxsplit: int + :rtype: list[T] + """ + pass + + def findall(self, string, pos=0, endpos=-1): + """Return a list of all non-overlapping matches of pattern in string. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: list[T] + """ + pass + + def finditer(self, string, pos=0, endpos=-1): + """Return an iterator over all non-overlapping matches for the + pattern in string. For each match, the iterator returns a + match object. + + :type string: T <= bytes | unicode + :type pos: int + :type endpos: int + :rtype: collections.Iterable[__Match[T]] + """ + pass + + def sub(self, repl, string, count=0): + """Return the string obtained by replacing the leftmost non-overlapping + occurrences of pattern in string by the replacement repl. + + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :rtype: T + """ + pass + + def subn(self, repl, string, count=0): + """Return the tuple (new_string, number_of_subs_made) found by replacing + the leftmost non-overlapping occurrences of pattern with the + replacement repl. + + :type repl: bytes | unicode | collections.Callable + :type string: T <= bytes | unicode + :type count: int + :rtype: (T, int) + """ + pass class __Match(object): - """Mock class for a match object.""" - - def __init__(self, pos, endpos, lastindex, lastgroup, re, string): - """Create a new match object. - - :type pos: int - :type endpos: int - :type lastindex: int | None - :type lastgroup: int | bytes | unicode | None - :type re: __Regex - :type string: bytes | unicode - :rtype: __Match[T] - """ - self.pos = pos - self.endpos = endpos - self.lastindex = lastindex - self.lastgroup = lastgroup - self.re = re - self.string = string - - def expand(self, template): - """Return the string obtained by doing backslash substitution on the - template string template. - - :type template: T - :rtype: T - """ - pass - - def group(self, *args): - """Return one or more subgroups of the match. - - :rtype: T | tuple - """ - pass - - def groups(self, default=None): - """Return a tuple containing all the subgroups of the match, from 1 up - to however many groups are in the pattern. - - :rtype: tuple - """ - pass - - def groupdict(self, default=None): - """Return a dictionary containing all the named subgroups of the match, - keyed by the subgroup name. - - :rtype: dict[bytes | unicode, T] - """ - pass - - def start(self, group=0): - """Return the index of the start of the substring matched by group. - - :type group: int | bytes | unicode - :rtype: int - """ - pass - - def end(self, group=0): - """Return the index of the end of the substring matched by group. - - :type group: int | bytes | unicode - :rtype: int - """ - pass - - def span(self, group=0): - """Return a 2-tuple (start, end) for the substring matched by group. - - :type group: int | bytes | unicode - :rtype: (int, int) - """ - pass + """Mock class for a match object.""" + + def __init__(self, pos, endpos, lastindex, lastgroup, re, string): + """Create a new match object. + + :type pos: int + :type endpos: int + :type lastindex: int | None + :type lastgroup: int | bytes | unicode | None + :type re: __Regex + :type string: bytes | unicode + :rtype: __Match[T] + """ + self.pos = pos + self.endpos = endpos + self.lastindex = lastindex + self.lastgroup = lastgroup + self.re = re + self.string = string + + def expand(self, template): + """Return the string obtained by doing backslash substitution on the + template string template. + + :type template: T + :rtype: T + """ + pass + + def group(self, *args): + """Return one or more subgroups of the match. + + :rtype: T | tuple + """ + pass + + def groups(self, default=None): + """Return a tuple containing all the subgroups of the match, from 1 up + to however many groups are in the pattern. + + :rtype: tuple + """ + pass + + def groupdict(self, default=None): + """Return a dictionary containing all the named subgroups of the match, + keyed by the subgroup name. + + :rtype: dict[bytes | unicode, T] + """ + pass + + def start(self, group=0): + """Return the index of the start of the substring matched by group. + + :type group: int | bytes | unicode + :rtype: int + """ + pass + + def end(self, group=0): + """Return the index of the end of the substring matched by group. + + :type group: int | bytes | unicode + :rtype: int + """ + pass + + def span(self, group=0): + """Return a 2-tuple (start, end) for the substring matched by group. + + :type group: int | bytes | unicode + :rtype: (int, int) + """ + pass diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index fed9abf..0b03369 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -7,32 +7,33 @@ from StringIO import StringIO try: - from lxml import etree - eTreeImported = True + from lxml import etree + + eTreeImported = True except ImportError: - eTreeImported = False + eTreeImported = False -inAnki='anki' in sys.modules +inAnki = 'anki' in sys.modules try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite ### Anknotes Imports from anknotes.shared import * from anknotes.error import * if inAnki: - ### Anknotes Class Imports - from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher - from anknotes.EvernoteNotePrototype import EvernoteNotePrototype + ### Anknotes Class Imports + from anknotes.EvernoteNoteFetcher import EvernoteNoteFetcher + from anknotes.EvernoteNotePrototype import EvernoteNotePrototype - ### Evernote Imports - from anknotes.evernote.edam.type.ttypes import Note as EvernoteNote - from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException - from anknotes.evernote.api.client import EvernoteClient + ### Evernote Imports + from anknotes.evernote.edam.type.ttypes import Note as EvernoteNote + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException, EDAMNotFoundException + from anknotes.evernote.api.client import EvernoteClient - from aqt.utils import openLink, getText, showInfo + from aqt.utils import openLink, getText, showInfo ### Anki Imports # import anki @@ -46,449 +47,499 @@ # from aqt import mw class Evernote(object): - metadata = {} - """:type : dict[str, evernote.edam.type.ttypes.Note]""" - notebook_data = {} - """:type : dict[str, anknotes.structs.EvernoteNotebook]""" - tag_data = {} - """:type : dict[str, anknotes.structs.EvernoteTag]""" - DTD = None - hasValidator = None - token = None - client = None - """:type : EvernoteClient """ - - def __init__(self): - global eTreeImported, dbLocal - self.tag_data = {} - self.notebook_data = {} - self.noteStore = None - self.getNoteCount = 0 - self.hasValidator = eTreeImported - if ankDBIsLocal(): - log("Skipping Evernote client load (DB is Local)", 'client') - return - self.setup_client() - - def setup_client(self): - auth_token = mw.col.conf.get(SETTINGS.EVERNOTE.AUTH_TOKEN, False) - if not auth_token: - # First run of the Plugin we did not save the access key yet - secrets = {'holycrepe': '36f46ea5dec83d4a', 'scriptkiddi-2682': '965f1873e4df583c'} - client = EvernoteClient( - consumer_key=EVERNOTE.API.CONSUMER_KEY, - consumer_secret=secrets[EVERNOTE.API.CONSUMER_KEY], - sandbox=EVERNOTE.API.IS_SANDBOXED - ) - request_token = client.get_request_token('https://fap-studios.de/anknotes/index.html') - url = client.get_authorize_url(request_token) - showInfo("We will open a Evernote Tab in your browser so you can allow access to your account") - openLink(url) - oauth_verifier = getText(prompt="Please copy the code that showed up, after allowing access, in here")[0] - auth_token = client.get_access_token( - request_token.get('oauth_token'), - request_token.get('oauth_token_secret'), - oauth_verifier) - mw.col.conf[SETTINGS.EVERNOTE.AUTH_TOKEN] = auth_token - else: client = EvernoteClient(token=auth_token, sandbox=EVERNOTE.API.IS_SANDBOXED) - self.token = auth_token - self.client = client - log("Set up Evernote Client", 'client') - - def initialize_note_store(self): - if self.noteStore: - return EvernoteAPIStatus.Success - api_action_str = u'trying to initialize the Evernote Note Store.' - log_api("get_note_store") - if not self.client: - log_error("Client does not exist for some reason. Did we not initialize Evernote Class? Current token: " + str(self.token)) - self.setup_client() - try: self.noteStore = self.client.get_note_store() - except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - return EvernoteAPIStatus.RateLimitError - except socket.error, v: - if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - return EvernoteAPIStatus.SocketError - return EvernoteAPIStatus.Success - - def loadDTD(self): - if self.DTD: return - timerInterval = stopwatch.Timer() - log("Loading ENML DTD", "lxml", timestamp=False, do_print=True) - self.DTD = etree.DTD(FILES.ANCILLARY.ENML_DTD) - log("DTD Loaded in %s\n" % str(timerInterval), "lxml", timestamp=False, do_print=True) - log(' > Note Validation: ENML DTD Loaded in %s' % str(timerInterval)) - del timerInterval - - def validateNoteBody(self, noteBody, title="Note Body"): - self.loadDTD() - noteBody = noteBody.replace('"http://xml.evernote.com/pub/enml2.dtd"', '"%s"' % convert_filename_to_local_link(FILES.ANCILLARY.ENML_DTD) ) - parser = etree.XMLParser(dtd_validation=True, attribute_defaults=True) - try: root = etree.fromstring(noteBody, parser) - except Exception as e: - log_str = "XML Loading of %s failed.\n - Error Details: %s" % (title, str(e)) - log(log_str, "lxml", timestamp=False, do_print=True) - log_error(log_str, False) - return False, [log_str] - try: success = self.DTD.validate(root) - except Exception as e: - log_str = "DTD Validation of %s failed.\n - Error Details: %s" % (title, str(e)) - log(log_str, "lxml", timestamp=False, do_print=True) - log_error(log_str, False) - return False, [log_str] - log("Validation %-9s for %s" % ("Succeeded" if success else "Failed", title), "lxml", timestamp=False, do_print=True) - errors = [str(x) for x in self.DTD.error_log.filter_from_errors()] - if not success: - log_str = "DTD Validation Errors for %s: \n%s\n" % (title, str(errors)) - log(log_str, "lxml", timestamp=False) - log_error(log_str, False) - return success, errors - - def validateNoteContent(self, content, title="Note Contents"): - """ - - :param content: Valid ENML without the <en-note></en-note> tags. Will be processed by makeNoteBody - :return: - """ - return self.validateNoteBody(self.makeNoteBody(content), title) - - def updateNote(self, guid, noteTitle, noteBody, tagNames=list(), parentNotebook=None, resources=None): - """ - Update a Note instance with title and body - Send Note object to user's account - :rtype : (EvernoteAPIStatus, evernote.edam.type.ttypes.Note) - :returns Status and Note - """ - if resources is None: resources = [] - return self.makeNote(noteTitle, noteBody, tagNames=tagNames, parentNotebook=parentNotebook, resources=resources, - guid=guid) - - @staticmethod - def makeNoteBody(content, resources=None, encode=True): - ## Build body of note - if resources is None: resources = [] - nBody = content - if not nBody.startswith("<?xml"): - nBody = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" - nBody += "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" - nBody += "<en-note>%s" % content + "</en-note>" - if encode and isinstance(nBody, unicode): - nBody = nBody.encode('utf-8') - return nBody - - @staticmethod - def addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=None, - guid=None): - if resources is None: resources = [] - sql = "FROM %s WHERE " % TABLES.NOTE_VALIDATION_QUEUE - if guid: - sql += "guid = '%s'" % guid - else: - sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) - statuses = ankDB().all('SELECT validation_status ' + sql) - if len(statuses) > 0: - if str(statuses[0]['validation_status']) == '1': return EvernoteAPIStatus.Success - ankDB().execute("DELETE " + sql) - # log_sql(sql) - # log_sql([ guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook]) - ankDB().execute( - "INSERT INTO %s(guid, title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?, ?)" % TABLES.NOTE_VALIDATION_QUEUE, - guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook) - return EvernoteAPIStatus.RequestQueued - - def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNotebook=None, resources=None, guid=None, - validated=None, enNote=None): - """ - Create or Update a Note instance with title and body - Send Note object to user's account - :type noteTitle: str - :param noteContents: Valid ENML without the <en-note></en-note> tags. Will be processed by makeNoteBody - :type enNote : EvernoteNotePrototype - :rtype : (EvernoteAPIStatus, EvernoteNote) - :returns Status and Note - """ - if enNote: guid, noteTitle, noteContents, tagNames, parentNotebook = enNote.Guid, enNote.FullTitle, enNote.Content, enNote.Tags, enNote.NotebookGuid or parentNotebook - if resources is None: resources = [] - callType = "create" - validation_status = EvernoteAPIStatus.Uninitialized - if validated is None: - if not EVERNOTE.UPLOAD.VALIDATION.ENABLED: validated = True - else: - validation_status = self.addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames, parentNotebook, resources, guid) - if not validation_status.IsSuccess and not self.hasValidator: return validation_status, None - - ourNote = EvernoteNote() - ourNote.title = noteTitle.encode('utf-8') - if guid: callType = "update"; ourNote.guid = guid - - ## Build body of note - nBody = self.makeNoteBody(noteContents, resources) - if validated is not True and not validation_status.IsSuccess: - success, errors = self.validateNoteBody(nBody, ourNote.title) - if not success: return EvernoteAPIStatus.UserError, None - ourNote.content = nBody - - notestore_status = self.initialize_note_store() - if not notestore_status.IsSuccess: return notestore_status, None - - while '' in tagNames: tagNames.remove('') - if len(tagNames) > 0: - if EVERNOTE.API.IS_SANDBOXED and not '#Sandbox' in tagNames: tagNames.append("#Sandbox") - ourNote.tagNames = tagNames - - ## parentNotebook is optional; if omitted, default notebook is used - if parentNotebook: - if hasattr(parentNotebook, 'guid'): ourNote.notebookGuid = parentNotebook.guid - elif hasattr(parentNotebook, 'Guid'): ourNote.notebookGuid = parentNotebook.Guid - elif isinstance(parentNotebook, str) or isinstance(parentNotebook, unicode): ourNote.notebookGuid = parentNotebook - - ## Attempt to create note in Evernote account - - api_action_str = u'trying to %s a note' % callType - log_api(callType + "Note", "'%s'" % noteTitle) - try: - note = getattr(self.noteStore, callType + 'Note')(self.token, ourNote) - except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - return EvernoteAPIStatus.RateLimitError, None - except socket.error, v: - if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - return EvernoteAPIStatus.SocketError, None - except EDAMUserException, edue: - ## Something was wrong with the note data - ## See EDAMErrorCode enumeration for error code explanation - ## http://dev.evernote.com/documentation/reference/Errors.html#Enum_EDAMErrorCode - print "EDAMUserException:", edue - log_error("-" * 50, crosspost_to_default=False) - log_error("EDAMUserException: " + str(edue), crosspost='api') - log_error(str(ourNote.tagNames), crosspost_to_default=False) - log_error(str(ourNote.content), crosspost_to_default=False) - log_error("-" * 50 + "\r\n", crosspost_to_default=False) - if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - return EvernoteAPIStatus.UserError, None - except EDAMNotFoundException, ednfe: - print "EDAMNotFoundException:", ednfe - log_error("-" * 50, crosspost_to_default=False) - log_error("EDAMNotFoundException: " + str(ednfe), crosspost='api') - if callType is "update": - log_error('GUID: ' + str(ourNote.guid), crosspost_to_default=False) - if ourNote.notebookGuid: - log_error('Notebook GUID: ' + str(ourNote.notebookGuid), crosspost_to_default=False) - log_error("-" * 50 + "\r\n", crosspost_to_default=False) - if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - return EvernoteAPIStatus.NotFoundError, None - except Exception, e: - print "Unknown Exception:", e - log_error("-" * 50, crosspost_to_default=False) - log_error("Unknown Exception: " + str(e)) - log_error(str(ourNote.tagNames), crosspost_to_default=False) - log_error(str(ourNote.content), crosspost_to_default=False) - log_error("-" * 50 + "\r\n", crosspost_to_default=False) - # return EvernoteAPIStatus.UnhandledError, None - raise - # noinspection PyUnboundLocalVariable - note.content = nBody - return EvernoteAPIStatus.Success, note - - def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): - global inAnki - """ - Create EvernoteNote objects from Evernote GUIDs using EvernoteNoteFetcher.getNote(). - Will prematurely return if fetcher.getNote fails - - :rtype : EvernoteNoteFetcherResults - :param evernote_guids: - :param use_local_db_only: Do not initiate API calls - :return: EvernoteNoteFetcherResults - """ - if not hasattr(self, 'evernote_guids') or evernote_guids: self.evernote_guids = evernote_guids - if not use_local_db_only: self.check_ancillary_data_up_to_date() - fetcher = EvernoteNoteFetcher(self, use_local_db_only=use_local_db_only) - if len(evernote_guids) == 0: fetcher.results.Status = EvernoteAPIStatus.EmptyRequest; return fetcher.results - if inAnki: - fetcher.evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() - fetcher.keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE) - fetcher.deleteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False) - fetcher.tagsToDelete = mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "").replace(',', ' ').split() - for evernote_guid in self.evernote_guids: - if not fetcher.getNote(evernote_guid): return fetcher.results - return fetcher.results - - def check_ancillary_data_up_to_date(self): - new_tags = 0 if self.check_tags_up_to_date() else self.update_tags_database("Tags were not up to date when checking ancillary data") - new_nbs = 0 if self.check_notebooks_up_to_date() else self.update_notebooks_database() - self.report_ancillary_data_results(new_tags, new_nbs, 'Forced ') - - def update_ancillary_data(self): - new_tags = self.update_tags_database("Manual call to update ancillary data") - new_nbs = self.update_notebooks_database() - self.report_ancillary_data_results(new_tags, new_nbs, 'Manual ', report_blank=True) - - @staticmethod - def report_ancillary_data_results(new_tags, new_nbs, title_prefix='', report_blank=False): - strr = '' - if new_tags is 0 and new_nbs is 0: - if not report_blank: return - strr = 'No new tags or notebooks found' - elif new_tags is None and new_nbs is None: strr = 'Error downloading ancillary data' - elif new_tags is None: strr = 'Error downloading tags list, and ' - elif new_nbs is None: strr = 'Error downloading notebooks list, and ' - - if new_tags > 0 and new_nbs > 0: strr = '%d new tag%s and %d new notebook%s found' % (new_tags, '' if new_tags is 1 else 's', new_nbs, '' if new_nbs is 1 else 's') - elif new_nbs > 0: strr += '%d new notebook%s found' % (new_nbs, '' if new_nbs is 1 else 's') - elif new_tags > 0: strr += '%d new tag%s found' % (new_tags, '' if new_tags is 1 else 's') - show_tooltip("%sUpdate of ancillary data complete: " % title_prefix + strr, do_log=True) - - def set_notebook_data(self): - if not hasattr(self, 'notebook_data') or not self.notebook_data or len(self.notebook_data.keys()) == 0: - self.notebook_data = {x['guid']: EvernoteNotebook(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS)} - def check_notebook_metadata(self, notes): - """ - :param notes: - :type : list[EvernoteNotePrototype] - :return: - """ - self.set_notebook_data() - for note in notes: - assert(isinstance(note, EvernoteNotePrototype)) - if note.NotebookGuid in self.notebook_data: continue - new_nbs = self.update_notebooks_database() - if note.NotebookGuid in self.notebook_data: - log("Missing notebook GUID %s for note %s when checking notebook metadata. Notebook was found after updating Anknotes' notebook database." + '' if new_nbs < 1 else ' In total, %d new notebooks were found.' % new_nbs) - continue - log_error("FATAL ERROR: Notebook GUID %s for Note %s: %s does not exist on Evernote servers" % (note.NotebookGuid, note.Guid, note.Title)) - raise EDAMNotFoundException() - return True - - def check_notebooks_up_to_date(self): - for evernote_guid in self.evernote_guids: - note_metadata = self.metadata[evernote_guid] - notebookGuid = note_metadata.notebookGuid - if not notebookGuid: - log_error(" > Notebook check: Unable to find notebook guid for '%s'. Returned '%s'. Metadata: %s" % ( - evernote_guid, str(notebookGuid), str(note_metadata)), crosspost_to_default=False) - elif notebookGuid not in self.notebook_data: - notebook = EvernoteNotebook(fetch_guid=notebookGuid) - if not notebook.success: - log(" > Notebook check: Missing notebook guid '%s'. Will update with an API call." % notebookGuid) - return False - self.notebook_data[notebookGuid] = notebook - return True - - def update_notebooks_database(self): - notestore_status = self.initialize_note_store() - if not notestore_status.IsSuccess: return None # notestore_status - api_action_str = u'trying to update Evernote notebooks.' - log_api("listNotebooks") - try: - notebooks = self.noteStore.listNotebooks(self.token) - """: type : list[evernote.edam.type.ttypes.Notebook] """ - except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - return None - except socket.error, v: - if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - return None - data = [] - self.notebook_data = {} - for notebook in notebooks: - self.notebook_data[notebook.guid] = {"stack": notebook.stack, "name": notebook.name} - data.append( - [notebook.guid, notebook.name, notebook.updateSequenceNum, notebook.serviceUpdated, notebook.stack]) - db = ankDB() - old_count = db.scalar("SELECT COUNT(*) FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS) - db.execute("DROP TABLE %s " % TABLES.EVERNOTE.NOTEBOOKS) - db.InitNotebooks(True) - # log_dump(data, 'update_notebooks_database table data', crosspost_to_default=False) - db.executemany( - "INSERT INTO `%s`(`guid`,`name`,`updateSequenceNum`,`serviceUpdated`, `stack`) VALUES (?, ?, ?, ?, ?)" % TABLES.EVERNOTE.NOTEBOOKS, - data) - db.commit() - # log_dump(ankDB().all("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS), 'sql data', crosspost_to_default=False) - return len(self.notebook_data) - old_count - - def update_tags_database(self, reason_str=''): - if hasattr(self, 'LastTagDBUpdate') and datetime.now() - self.LastTagDBUpdate < timedelta(minutes=15): - return None - self.LastTagDBUpdate = datetime.now() - notestore_status = self.initialize_note_store() - if not notestore_status.IsSuccess: return None # notestore_status - api_action_str = u'trying to update Evernote tags.' - log_api("listTags" + (': ' + reason_str) if reason_str else '') - try: - tags = self.noteStore.listTags(self.token) - """: type : list[evernote.edam.type.ttypes.Tag] """ - except EDAMSystemException as e: - if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - return None - except socket.error, v: - if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise - return None - data = [] - self.tag_data = {} - enTag = None - for tag in tags: - enTag = EvernoteTag(tag) - self.tag_data[enTag.Guid] = enTag - data.append(enTag.items()) - if not enTag: return None - db=ankDB() - old_count=db.scalar("SELECT COUNT(*) FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS) - ankDB().execute("DROP TABLE %s " % TABLES.EVERNOTE.TAGS) - ankDB().InitTags(True) - ankDB().executemany(enTag.sqlUpdateQuery(), data) - ankDB().commit() - return len(self.tag_data) - old_count - - def set_tag_data(self): - if not hasattr(self, 'tag_data') or not self.tag_data or len(self.tag_data.keys()) == 0: - self.tag_data = {x['guid']: EvernoteTag(x) for x in ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS)} - def get_missing_tags(self, current_tags, from_guids=True): - if isinstance(current_tags, list): current_tags = set(current_tags) - self.set_tag_data() - all_tags = set(self.tag_data.keys() if from_guids else [v.Name for k, v in self.tag_data.items()]) - missing_tags = current_tags - all_tags - if missing_tags: - log_error("Missing Tag %s(s) were found:\nMissing: %s\n\nCurrent: %s\n\nAll Tags: %s\n\nTag Data: %s" % ('Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)), ', '.join(sorted(current_tags)), ', '.join(sorted(all_tags)), str(self.tag_data))) - return missing_tags - def get_matching_tag_data(self, tag_guids=None, tag_names=None): - tagGuids = [] - tagNames = [] - assert tag_guids or tag_names - from_guids = True if (tag_guids is not None) else False - tags_original = tag_guids if from_guids else tag_names - if self.get_missing_tags(tags_original, from_guids): - self.update_tags_database("Missing Tag %s(s) Were found when attempting to get matching tag data" % ('Guids' if from_guids else 'Names')) - missing_tags = self.get_missing_tags(tags_original, from_guids) - if missing_tags: - identifier = 'Guid' if from_guids else 'Name' - keys = ', '.join(sorted(missing_tags)) - log_error("FATAL ERROR: Tag %s(s) %s were not found on the Evernote Servers" % (identifier, keys)) - raise EDAMNotFoundException(identifier.lower(), keys) - if from_guids: tags_dict = {x: self.tag_data[x] for x in tags_original} - else: tags_dict = {[k for k, v in self.tag_data.items() if v.Name is tag_name][0]: tag_name for tag_name in tags_original} - tagNamesToImport = get_tag_names_to_import(tags_dict) - """:type : dict[string, EvernoteTag]""" - if tagNamesToImport: - is_struct = None - for k, v in tagNamesToImport.items(): - if is_struct is None: is_struct = isinstance(v, EvernoteTag) - tagGuids.append(k) - tagNames.append(v.Name if is_struct else v) - tagNames = sorted(tagNames, key=lambda s: s.lower()) - return tagGuids, tagNames - - def check_tags_up_to_date(self): - for evernote_guid in self.evernote_guids: - if evernote_guid not in self.metadata: - log_error('Could not find note metadata for Note ''%s''' % evernote_guid) - return False - note_metadata = self.metadata[evernote_guid] - if not note_metadata.tagGuids: continue - for tag_guid in note_metadata.tagGuids: - if tag_guid in self.tag_data: continue - tag = EvernoteTag(fetch_guid=tag_guid) - if not tag.success: return False - self.tag_data[tag_guid] = tag - return True + metadata = {} + """:type : dict[str, evernote.edam.type.ttypes.Note]""" + notebook_data = {} + """:type : dict[str, anknotes.structs.EvernoteNotebook]""" + tag_data = {} + """:type : dict[str, anknotes.structs.EvernoteTag]""" + DTD = None + hasValidator = None + token = None + client = None + """:type : EvernoteClient """ + + def __init__(self): + global eTreeImported, dbLocal + self.tag_data = {} + self.notebook_data = {} + self.noteStore = None + self.getNoteCount = 0 + self.hasValidator = eTreeImported + if ankDBIsLocal(): + log("Skipping Evernote client load (DB is Local)", 'client') + return + self.setup_client() + + def setup_client(self): + auth_token = mw.col.conf.get(SETTINGS.EVERNOTE.AUTH_TOKEN, False) + if not auth_token: + # First run of the Plugin we did not save the access key yet + secrets = {'holycrepe': '36f46ea5dec83d4a', 'scriptkiddi-2682': '965f1873e4df583c'} + client = EvernoteClient( + consumer_key=EVERNOTE.API.CONSUMER_KEY, + consumer_secret=secrets[EVERNOTE.API.CONSUMER_KEY], + sandbox=EVERNOTE.API.IS_SANDBOXED + ) + request_token = client.get_request_token('https://fap-studios.de/anknotes/index.html') + url = client.get_authorize_url(request_token) + showInfo("We will open a Evernote Tab in your browser so you can allow access to your account") + openLink(url) + oauth_verifier = getText(prompt="Please copy the code that showed up, after allowing access, in here")[0] + auth_token = client.get_access_token( + request_token.get('oauth_token'), + request_token.get('oauth_token_secret'), + oauth_verifier) + mw.col.conf[SETTINGS.EVERNOTE.AUTH_TOKEN] = auth_token + else: + client = EvernoteClient(token=auth_token, sandbox=EVERNOTE.API.IS_SANDBOXED) + self.token = auth_token + self.client = client + log("Set up Evernote Client", 'client') + + def initialize_note_store(self): + if self.noteStore: + return EvernoteAPIStatus.Success + api_action_str = u'trying to initialize the Evernote Note Store.' + log_api("get_note_store") + if not self.client: + log_error( + "Client does not exist for some reason. Did we not initialize Evernote Class? Current token: " + str( + self.token)) + self.setup_client() + try: + self.noteStore = self.client.get_note_store() + except EDAMSystemException as e: + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return EvernoteAPIStatus.RateLimitError + except socket.error, v: + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return EvernoteAPIStatus.SocketError + return EvernoteAPIStatus.Success + + def loadDTD(self): + if self.DTD: return + timerInterval = stopwatch.Timer() + log("Loading ENML DTD", "lxml", timestamp=False, do_print=True) + self.DTD = etree.DTD(FILES.ANCILLARY.ENML_DTD) + log("DTD Loaded in %s\n" % str(timerInterval), "lxml", timestamp=False, do_print=True) + log(' > Note Validation: ENML DTD Loaded in %s' % str(timerInterval)) + del timerInterval + + def validateNoteBody(self, noteBody, title="Note Body"): + self.loadDTD() + noteBody = noteBody.replace('"http://xml.evernote.com/pub/enml2.dtd"', + '"%s"' % convert_filename_to_local_link(FILES.ANCILLARY.ENML_DTD)) + parser = etree.XMLParser(dtd_validation=True, attribute_defaults=True) + try: + root = etree.fromstring(noteBody, parser) + except Exception as e: + log_str = "XML Loading of %s failed.\n - Error Details: %s" % (title, str(e)) + log(log_str, "lxml", timestamp=False, do_print=True) + log_error(log_str, False) + return False, [log_str] + try: + success = self.DTD.validate(root) + except Exception as e: + log_str = "DTD Validation of %s failed.\n - Error Details: %s" % (title, str(e)) + log(log_str, "lxml", timestamp=False, do_print=True) + log_error(log_str, False) + return False, [log_str] + log("Validation %-9s for %s" % ("Succeeded" if success else "Failed", title), "lxml", timestamp=False, + do_print=True) + errors = [str(x) for x in self.DTD.error_log.filter_from_errors()] + if not success: + log_str = "DTD Validation Errors for %s: \n%s\n" % (title, str(errors)) + log(log_str, "lxml", timestamp=False) + log_error(log_str, False) + return success, errors + + def validateNoteContent(self, content, title="Note Contents"): + """ + + :param content: Valid ENML without the <en-note></en-note> tags. Will be processed by makeNoteBody + :return: + """ + return self.validateNoteBody(self.makeNoteBody(content), title) + + def updateNote(self, guid, noteTitle, noteBody, tagNames=list(), parentNotebook=None, resources=None): + """ + Update a Note instance with title and body + Send Note object to user's account + :rtype : (EvernoteAPIStatus, evernote.edam.type.ttypes.Note) + :returns Status and Note + """ + if resources is None: resources = [] + return self.makeNote(noteTitle, noteBody, tagNames=tagNames, parentNotebook=parentNotebook, resources=resources, + guid=guid) + + @staticmethod + def makeNoteBody(content, resources=None, encode=True): + ## Build body of note + if resources is None: resources = [] + nBody = content + if not nBody.startswith("<?xml"): + nBody = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + nBody += "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" + nBody += "<en-note>%s" % content + "</en-note>" + if encode and isinstance(nBody, unicode): + nBody = nBody.encode('utf-8') + return nBody + + @staticmethod + def addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=None, + guid=None): + if resources is None: resources = [] + sql = "FROM %s WHERE " % TABLES.NOTE_VALIDATION_QUEUE + if guid: + sql += "guid = '%s'" % guid + else: + sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) + statuses = ankDB().all('SELECT validation_status ' + sql) + if len(statuses) > 0: + if str(statuses[0]['validation_status']) == '1': return EvernoteAPIStatus.Success + ankDB().execute("DELETE " + sql) + # log_sql(sql) + # log_sql([ guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook]) + ankDB().execute( + "INSERT INTO %s(guid, title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?, ?)" % TABLES.NOTE_VALIDATION_QUEUE, + guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook) + return EvernoteAPIStatus.RequestQueued + + def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNotebook=None, resources=None, + guid=None, + validated=None, enNote=None): + """ + Create or Update a Note instance with title and body + Send Note object to user's account + :type noteTitle: str + :param noteContents: Valid ENML without the <en-note></en-note> tags. Will be processed by makeNoteBody + :type enNote : EvernoteNotePrototype + :rtype : (EvernoteAPIStatus, EvernoteNote) + :returns Status and Note + """ + if enNote: guid, noteTitle, noteContents, tagNames, parentNotebook = enNote.Guid, enNote.FullTitle, enNote.Content, enNote.Tags, enNote.NotebookGuid or parentNotebook + if resources is None: resources = [] + callType = "create" + validation_status = EvernoteAPIStatus.Uninitialized + if validated is None: + if not EVERNOTE.UPLOAD.VALIDATION.ENABLED: + validated = True + else: + validation_status = self.addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames, parentNotebook, + resources, guid) + if not validation_status.IsSuccess and not self.hasValidator: return validation_status, None + + ourNote = EvernoteNote() + ourNote.title = noteTitle.encode('utf-8') + if guid: callType = "update"; ourNote.guid = guid + + ## Build body of note + nBody = self.makeNoteBody(noteContents, resources) + if validated is not True and not validation_status.IsSuccess: + success, errors = self.validateNoteBody(nBody, ourNote.title) + if not success: return EvernoteAPIStatus.UserError, None + ourNote.content = nBody + + notestore_status = self.initialize_note_store() + if not notestore_status.IsSuccess: return notestore_status, None + + while '' in tagNames: tagNames.remove('') + if len(tagNames) > 0: + if EVERNOTE.API.IS_SANDBOXED and not '#Sandbox' in tagNames: tagNames.append("#Sandbox") + ourNote.tagNames = tagNames + + ## parentNotebook is optional; if omitted, default notebook is used + if parentNotebook: + if hasattr(parentNotebook, 'guid'): + ourNote.notebookGuid = parentNotebook.guid + elif hasattr(parentNotebook, 'Guid'): + ourNote.notebookGuid = parentNotebook.Guid + elif isinstance(parentNotebook, str) or isinstance(parentNotebook, unicode): + ourNote.notebookGuid = parentNotebook + + ## Attempt to create note in Evernote account + + api_action_str = u'trying to %s a note' % callType + log_api(callType + "Note", "'%s'" % noteTitle) + try: + note = getattr(self.noteStore, callType + 'Note')(self.token, ourNote) + except EDAMSystemException as e: + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return EvernoteAPIStatus.RateLimitError, None + except socket.error, v: + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return EvernoteAPIStatus.SocketError, None + except EDAMUserException, edue: + ## Something was wrong with the note data + ## See EDAMErrorCode enumeration for error code explanation + ## http://dev.evernote.com/documentation/reference/Errors.html#Enum_EDAMErrorCode + print + "EDAMUserException:", edue + log_error("-" * 50, crosspost_to_default=False) + log_error("EDAMUserException: " + str(edue), crosspost='api') + log_error(str(ourNote.tagNames), crosspost_to_default=False) + log_error(str(ourNote.content), crosspost_to_default=False) + log_error("-" * 50 + "\r\n", crosspost_to_default=False) + if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return EvernoteAPIStatus.UserError, None + except EDAMNotFoundException, ednfe: + print + "EDAMNotFoundException:", ednfe + log_error("-" * 50, crosspost_to_default=False) + log_error("EDAMNotFoundException: " + str(ednfe), crosspost='api') + if callType is "update": + log_error('GUID: ' + str(ourNote.guid), crosspost_to_default=False) + if ourNote.notebookGuid: + log_error('Notebook GUID: ' + str(ourNote.notebookGuid), crosspost_to_default=False) + log_error("-" * 50 + "\r\n", crosspost_to_default=False) + if EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return EvernoteAPIStatus.NotFoundError, None + except Exception, e: + print + "Unknown Exception:", e + log_error("-" * 50, crosspost_to_default=False) + log_error("Unknown Exception: " + str(e)) + log_error(str(ourNote.tagNames), crosspost_to_default=False) + log_error(str(ourNote.content), crosspost_to_default=False) + log_error("-" * 50 + "\r\n", crosspost_to_default=False) + # return EvernoteAPIStatus.UnhandledError, None + raise + # noinspection PyUnboundLocalVariable + note.content = nBody + return EvernoteAPIStatus.Success, note + + def create_evernote_notes(self, evernote_guids=None, use_local_db_only=False): + global inAnki + """ + Create EvernoteNote objects from Evernote GUIDs using EvernoteNoteFetcher.getNote(). + Will prematurely return if fetcher.getNote fails + + :rtype : EvernoteNoteFetcherResults + :param evernote_guids: + :param use_local_db_only: Do not initiate API calls + :return: EvernoteNoteFetcherResults + """ + if not hasattr(self, 'evernote_guids') or evernote_guids: self.evernote_guids = evernote_guids + if not use_local_db_only: self.check_ancillary_data_up_to_date() + fetcher = EvernoteNoteFetcher(self, use_local_db_only=use_local_db_only) + if not evernote_guids or len(evernote_guids) == 0: + fetcher.results.Status = EvernoteAPIStatus.EmptyRequest + else: + return fetcher.results + if inAnki: + fetcher.evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, + SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', + ' ').split() + fetcher.keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, + SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE) + fetcher.deleteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False) + fetcher.tagsToDelete = mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "").replace(',', ' ').split() + if self.evernote_guids: + for evernote_guid in self.evernote_guids: + if not fetcher.getNote(evernote_guid): + return fetcher.results + return fetcher.results + + def check_ancillary_data_up_to_date(self): + new_tags = 0 if self.check_tags_up_to_date() else self.update_tags_database( + "Tags were not up to date when checking ancillary data") + new_nbs = 0 if self.check_notebooks_up_to_date() else self.update_notebooks_database() + self.report_ancillary_data_results(new_tags, new_nbs, 'Forced ') + + def update_ancillary_data(self): + new_tags = self.update_tags_database("Manual call to update ancillary data") + new_nbs = self.update_notebooks_database() + self.report_ancillary_data_results(new_tags, new_nbs, 'Manual ', report_blank=True) + + @staticmethod + def report_ancillary_data_results(new_tags, new_nbs, title_prefix='', report_blank=False): + strr = '' + if new_tags is 0 and new_nbs is 0: + if not report_blank: return + strr = 'No new tags or notebooks found' + elif new_tags is None and new_nbs is None: + strr = 'Error downloading ancillary data' + elif new_tags is None: + strr = 'Error downloading tags list, and ' + elif new_nbs is None: + strr = 'Error downloading notebooks list, and ' + + if new_tags > 0 and new_nbs > 0: + strr = '%d new tag%s and %d new notebook%s found' % ( + new_tags, '' if new_tags is 1 else 's', new_nbs, '' if new_nbs is 1 else 's') + elif new_nbs > 0: + strr += '%d new notebook%s found' % (new_nbs, '' if new_nbs is 1 else 's') + elif new_tags > 0: + strr += '%d new tag%s found' % (new_tags, '' if new_tags is 1 else 's') + show_tooltip("%sUpdate of ancillary data complete: " % title_prefix + strr, do_log=True) + + def set_notebook_data(self): + if not hasattr(self, 'notebook_data') or not self.notebook_data or len(self.notebook_data.keys()) == 0: + self.notebook_data = {x['guid']: EvernoteNotebook(x) for x in + ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS)} + + def check_notebook_metadata(self, notes): + """ + :param notes: + :type : list[EvernoteNotePrototype] + :return: + """ + self.set_notebook_data() + for note in notes: + assert (isinstance(note, EvernoteNotePrototype)) + if note.NotebookGuid in self.notebook_data: continue + new_nbs = self.update_notebooks_database() + if note.NotebookGuid in self.notebook_data: + log( + "Missing notebook GUID %s for note %s when checking notebook metadata. Notebook was found after updating Anknotes' notebook database." + '' if new_nbs < 1 else ' In total, %d new notebooks were found.' % new_nbs) + continue + log_error("FATAL ERROR: Notebook GUID %s for Note %s: %s does not exist on Evernote servers" % ( + note.NotebookGuid, note.Guid, note.Title)) + raise EDAMNotFoundException() + return True + + def check_notebooks_up_to_date(self): + if not self.evernote_guids: + return True + for evernote_guid in self.evernote_guids: + note_metadata = self.metadata[evernote_guid] + notebookGuid = note_metadata.notebookGuid + if not notebookGuid: + log_error(" > Notebook check: Unable to find notebook guid for '%s'. Returned '%s'. Metadata: %s" % ( + evernote_guid, str(notebookGuid), str(note_metadata)), crosspost_to_default=False) + elif notebookGuid not in self.notebook_data: + notebook = EvernoteNotebook(fetch_guid=notebookGuid) + if not notebook.success: + log(" > Notebook check: Missing notebook guid '%s'. Will update with an API call." % notebookGuid) + return False + self.notebook_data[notebookGuid] = notebook + return True + + def update_notebooks_database(self): + notestore_status = self.initialize_note_store() + if not notestore_status.IsSuccess: return None # notestore_status + api_action_str = u'trying to update Evernote notebooks.' + log_api("listNotebooks") + try: + notebooks = self.noteStore.listNotebooks(self.token) + """: type : list[evernote.edam.type.ttypes.Notebook] """ + except EDAMSystemException as e: + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return None + except socket.error, v: + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return None + data = [] + self.notebook_data = {} + for notebook in notebooks: + self.notebook_data[notebook.guid] = {"stack": notebook.stack, "name": notebook.name} + data.append( + [notebook.guid, notebook.name, notebook.updateSequenceNum, notebook.serviceUpdated, notebook.stack]) + db = ankDB() + old_count = db.scalar("SELECT COUNT(*) FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS) + db.execute("DROP TABLE %s " % TABLES.EVERNOTE.NOTEBOOKS) + db.InitNotebooks(True) + # log_dump(data, 'update_notebooks_database table data', crosspost_to_default=False) + db.executemany( + "INSERT INTO `%s`(`guid`,`name`,`updateSequenceNum`,`serviceUpdated`, `stack`) VALUES (?, ?, ?, ?, ?)" % TABLES.EVERNOTE.NOTEBOOKS, + data) + db.commit() + # log_dump(ankDB().all("SELECT * FROM %s WHERE 1" % TABLES.EVERNOTE.NOTEBOOKS), 'sql data', crosspost_to_default=False) + return len(self.notebook_data) - old_count + + def update_tags_database(self, reason_str=''): + if hasattr(self, 'LastTagDBUpdate') and datetime.now() - self.LastTagDBUpdate < timedelta(minutes=15): + return None + self.LastTagDBUpdate = datetime.now() + notestore_status = self.initialize_note_store() + if not notestore_status.IsSuccess: return None # notestore_status + api_action_str = u'trying to update Evernote tags.' + log_api("listTags" + (': ' + reason_str) if reason_str else '') + try: + tags = self.noteStore.listTags(self.token) + """: type : list[evernote.edam.type.ttypes.Tag] """ + except EDAMSystemException as e: + if not HandleEDAMRateLimitError(e, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return None + except socket.error, v: + if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise + return None + data = [] + self.tag_data = {} + enTag = None + for tag in tags: + enTag = EvernoteTag(tag) + self.tag_data[enTag.Guid] = enTag + data.append(enTag.items()) + if not enTag: return None + db = ankDB() + old_count = db.scalar("SELECT COUNT(*) FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS) + ankDB().execute("DROP TABLE %s " % TABLES.EVERNOTE.TAGS) + ankDB().InitTags(True) + ankDB().executemany(enTag.sqlUpdateQuery(), data) + ankDB().commit() + return len(self.tag_data) - old_count + + def set_tag_data(self): + if not hasattr(self, 'tag_data') or not self.tag_data or len(self.tag_data.keys()) == 0: + self.tag_data = {x['guid']: EvernoteTag(x) for x in + ankDB().execute("SELECT guid, name FROM %s WHERE 1" % TABLES.EVERNOTE.TAGS)} + + def get_missing_tags(self, current_tags, from_guids=True): + if isinstance(current_tags, list): current_tags = set(current_tags) + self.set_tag_data() + all_tags = set(self.tag_data.keys() if from_guids else [v.Name for k, v in self.tag_data.items()]) + missing_tags = current_tags - all_tags + if missing_tags: + log_error("Missing Tag %s(s) were found:\nMissing: %s\n\nCurrent: %s\n\nAll Tags: %s\n\nTag Data: %s" % ( + 'Guids' if from_guids else 'Names', ', '.join(sorted(missing_tags)), ', '.join(sorted(current_tags)), + ', '.join(sorted(all_tags)), str(self.tag_data))) + return missing_tags + + def get_matching_tag_data(self, tag_guids=None, tag_names=None): + tagGuids = [] + tagNames = [] + assert tag_guids or tag_names + from_guids = True if (tag_guids is not None) else False + tags_original = tag_guids if from_guids else tag_names + if self.get_missing_tags(tags_original, from_guids): + self.update_tags_database("Missing Tag %s(s) Were found when attempting to get matching tag data" % ( + 'Guids' if from_guids else 'Names')) + missing_tags = self.get_missing_tags(tags_original, from_guids) + if missing_tags: + identifier = 'Guid' if from_guids else 'Name' + keys = ', '.join(sorted(missing_tags)) + log_error("FATAL ERROR: Tag %s(s) %s were not found on the Evernote Servers" % (identifier, keys)) + raise EDAMNotFoundException(identifier.lower(), keys) + if from_guids: + tags_dict = {x: self.tag_data[x] for x in tags_original} + else: + tags_dict = {[k for k, v in self.tag_data.items() if v.Name is tag_name][0]: tag_name for tag_name in + tags_original} + tagNamesToImport = get_tag_names_to_import(tags_dict) + """:type : dict[string, EvernoteTag]""" + if tagNamesToImport: + is_struct = None + for k, v in tagNamesToImport.items(): + if is_struct is None: is_struct = isinstance(v, EvernoteTag) + tagGuids.append(k) + tagNames.append(v.Name if is_struct else v) + tagNames = sorted(tagNames, key=lambda s: s.lower()) + return tagGuids, tagNames + + def check_tags_up_to_date(self): + if not self.evernote_guids: + return True # This is if we dont have any notes we cann not find any metadata + for evernote_guid in self.evernote_guids: + if evernote_guid not in self.metadata: + log_error('Could not find note metadata for Note ''%s''' % evernote_guid) + return False + note_metadata = self.metadata[evernote_guid] + if not note_metadata.tagGuids: continue + for tag_guid in note_metadata.tagGuids: + if tag_guid in self.tag_data: continue + tag = EvernoteTag(fetch_guid=tag_guid) + if not tag.success: return False + self.tag_data[tag_guid] = tag + return True diff --git a/anknotes/constants.py b/anknotes/constants.py index 2d29876..559b82f 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -2,203 +2,243 @@ import os PATH = os.path.dirname(os.path.abspath(__file__)) + + class FOLDERS: - ADDONS = os.path.dirname(PATH) - EXTRA = os.path.join(PATH, 'extra') - ANCILLARY = os.path.join(EXTRA, 'ancillary') - GRAPHICS = os.path.join(EXTRA, 'graphics') - LOGS = os.path.join(EXTRA, 'logs') - DEVELOPER = os.path.join(EXTRA, 'dev') - USER = os.path.join(EXTRA, 'user') + ADDONS = os.path.dirname(PATH) + EXTRA = os.path.join(PATH, 'extra') + ANCILLARY = os.path.join(EXTRA, 'ancillary') + GRAPHICS = os.path.join(EXTRA, 'graphics') + LOGS = os.path.join(EXTRA, 'logs') + DEVELOPER = os.path.join(EXTRA, 'dev') + USER = os.path.join(EXTRA, 'user') + class FILES: - class LOGS: - class FDN: - ANKI_ORPHANS = 'Find Deleted Notes\\' - UNIMPORTED_EVERNOTE_NOTES = ANKI_ORPHANS + 'UnimportedEvernoteNotes' - ANKI_TITLE_MISMATCHES = ANKI_ORPHANS + 'AnkiTitleMismatches' - ANKNOTES_TITLE_MISMATCHES = ANKI_ORPHANS + 'AnknotesTitleMismatches' - ANKNOTES_ORPHANS = ANKI_ORPHANS + 'AnknotesOrphans' - ANKI_ORPHANS += 'AnkiOrphans' - BASE_NAME = '' - DEFAULT_NAME = 'anknotes' - MAIN = DEFAULT_NAME - ACTIVE = DEFAULT_NAME - USE_CALLER_NAME = False - class ANCILLARY: - TEMPLATE = os.path.join(FOLDERS.ANCILLARY, 'FrontTemplate.htm') - CSS = u'_AviAnkiCSS.css' - CSS_QMESSAGEBOX = os.path.join(FOLDERS.ANCILLARY, 'QMessageBox.css') - ENML_DTD = os.path.join(FOLDERS.ANCILLARY, 'enml2.dtd') - class SCRIPTS: - VALIDATION = os.path.join(FOLDERS.ADDONS, 'anknotes_start_note_validation.py') - FIND_DELETED_NOTES = os.path.join(FOLDERS.ADDONS, 'anknotes_start_find_deleted_notes.py') - class GRAPHICS: - class ICON: - EVERNOTE_WEB = os.path.join(FOLDERS.GRAPHICS, u'evernote_web.ico') - EVERNOTE_ARTCORE = os.path.join(FOLDERS.GRAPHICS, u'evernote_artcore.ico') - TOMATO = os.path.join(FOLDERS.GRAPHICS, u'Tomato-icon.ico') - class IMAGE: - pass - IMAGE.EVERNOTE_WEB = ICON.EVERNOTE_WEB.replace('.ico', '.png') - IMAGE.EVERNOTE_ARTCORE = ICON.EVERNOTE_ARTCORE.replace('.ico', '.png') - class USER: - TABLE_OF_CONTENTS_ENEX = os.path.join(FOLDERS.USER, "Table of Contents.enex") - LAST_PROFILE_LOCATION = os.path.join(FOLDERS.USER, 'anki.profile') + class LOGS: + class FDN: + ANKI_ORPHANS = 'Find Deleted Notes\\' + UNIMPORTED_EVERNOTE_NOTES = ANKI_ORPHANS + 'UnimportedEvernoteNotes' + ANKI_TITLE_MISMATCHES = ANKI_ORPHANS + 'AnkiTitleMismatches' + ANKNOTES_TITLE_MISMATCHES = ANKI_ORPHANS + 'AnknotesTitleMismatches' + ANKNOTES_ORPHANS = ANKI_ORPHANS + 'AnknotesOrphans' + ANKI_ORPHANS += 'AnkiOrphans' + + BASE_NAME = '' + DEFAULT_NAME = 'anknotes' + MAIN = DEFAULT_NAME + ACTIVE = DEFAULT_NAME + USE_CALLER_NAME = False + + class ANCILLARY: + TEMPLATE = os.path.join(FOLDERS.ANCILLARY, 'FrontTemplate.htm') + CSS = u'_AviAnkiCSS.css' + CSS_QMESSAGEBOX = os.path.join(FOLDERS.ANCILLARY, 'QMessageBox.css') + ENML_DTD = os.path.join(FOLDERS.ANCILLARY, 'enml2.dtd') + + class SCRIPTS: + VALIDATION = os.path.join(FOLDERS.ADDONS, 'anknotes_start_note_validation.py') + FIND_DELETED_NOTES = os.path.join(FOLDERS.ADDONS, 'anknotes_start_find_deleted_notes.py') + + class GRAPHICS: + class ICON: + EVERNOTE_WEB = os.path.join(FOLDERS.GRAPHICS, u'evernote_web.ico') + EVERNOTE_ARTCORE = os.path.join(FOLDERS.GRAPHICS, u'evernote_artcore.ico') + TOMATO = os.path.join(FOLDERS.GRAPHICS, u'Tomato-icon.ico') + + class IMAGE: + pass + + IMAGE.EVERNOTE_WEB = ICON.EVERNOTE_WEB.replace('.ico', '.png') + IMAGE.EVERNOTE_ARTCORE = ICON.EVERNOTE_ARTCORE.replace('.ico', '.png') + + class USER: + TABLE_OF_CONTENTS_ENEX = os.path.join(FOLDERS.USER, "Table of Contents.enex") + LAST_PROFILE_LOCATION = os.path.join(FOLDERS.USER, 'anki.profile') + class ANKNOTES: - DATE_FORMAT = '%Y-%m-%d %H:%M:%S' - class DEVELOPER_MODE: - ENABLED = (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer'))) - AUTOMATED = ENABLED and (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer.automate'))) - AUTO_RELOAD_MODULES = True - class HIERARCHY: - ROOT_TITLES_BASE_QUERY = "notebookGuid != 'fdccbccf-ee70-4069-a587-82772a96d9d3'" - class FORMATTING: - BANNER_MINIMUM = 80 - COUNTER_BANNER_MINIMUM = 40 - LINE_PADDING_HEADER = 31 - LINE_LENGTH = 185 - LIST_PAD = 25 - PROGRESS_SUMMARY_PAD = 31 - PPRINT_WIDTH = 80 - TIMESTAMP_PAD = '\t'*6 - TIMESTAMP_PAD_LENGTH = len(TIMESTAMP_PAD.replace('\t', ' '*4)) + DATE_FORMAT = '%Y-%m-%d %H:%M:%S' + + class DEVELOPER_MODE: + ENABLED = (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer'))) + AUTOMATED = ENABLED and (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer.automate'))) + AUTO_RELOAD_MODULES = True + + class HIERARCHY: + ROOT_TITLES_BASE_QUERY = "notebookGuid != 'fdccbccf-ee70-4069-a587-82772a96d9d3'" + + class FORMATTING: + BANNER_MINIMUM = 80 + COUNTER_BANNER_MINIMUM = 40 + LINE_PADDING_HEADER = 31 + LINE_LENGTH = 185 + LIST_PAD = 25 + PROGRESS_SUMMARY_PAD = 31 + PPRINT_WIDTH = 80 + TIMESTAMP_PAD = '\t' * 6 + TIMESTAMP_PAD_LENGTH = len(TIMESTAMP_PAD.replace('\t', ' ' * 4)) + class MODELS: - class TYPES: - CLOZE = 1 - class OPTIONS: - IMPORT_STYLES = True - DEFAULT = 'evernote_note' - REVERSIBLE = 'evernote_note_reversible' - REVERSE_ONLY = 'evernote_note_reverse_only' - CLOZE = 'evernote_note_cloze' + class TYPES: + CLOZE = 1 + + class OPTIONS: + IMPORT_STYLES = True + + DEFAULT = 'evernote_note' + REVERSIBLE = 'evernote_note_reversible' + REVERSE_ONLY = 'evernote_note_reverse_only' + CLOZE = 'evernote_note_cloze' + class TEMPLATES: - DEFAULT = 'EvernoteReview' - REVERSED = 'EvernoteReviewReversed' - CLOZE = 'EvernoteReviewCloze' + DEFAULT = 'EvernoteReview' + REVERSED = 'EvernoteReviewReversed' + CLOZE = 'EvernoteReviewCloze' class FIELDS: - TITLE = 'Title' - CONTENT = 'Content' - SEE_ALSO = 'See_Also' - TOC = 'TOC' - OUTLINE = 'Outline' - EXTRA = 'Extra' - EVERNOTE_GUID = 'Evernote GUID' - UPDATE_SEQUENCE_NUM = 'updateSequenceNum' - EVERNOTE_GUID_PREFIX = 'evernote_guid=' - LIST = [TITLE, CONTENT, SEE_ALSO, EXTRA, TOC, OUTLINE, - UPDATE_SEQUENCE_NUM] - class ORD: - pass - ORD.CONTENT = LIST.index(CONTENT) + 1 - ORD.SEE_ALSO = LIST.index(SEE_ALSO) + 1 + TITLE = 'Title' + CONTENT = 'Content' + SEE_ALSO = 'See_Also' + TOC = 'TOC' + OUTLINE = 'Outline' + EXTRA = 'Extra' + EVERNOTE_GUID = 'Evernote GUID' + UPDATE_SEQUENCE_NUM = 'updateSequenceNum' + EVERNOTE_GUID_PREFIX = 'evernote_guid=' + LIST = [TITLE, CONTENT, SEE_ALSO, EXTRA, TOC, OUTLINE, + UPDATE_SEQUENCE_NUM] + + class ORD: + pass + + ORD.CONTENT = LIST.index(CONTENT) + 1 + ORD.SEE_ALSO = LIST.index(SEE_ALSO) + 1 + class DECKS: - DEFAULT = "Evernote" - TOC_SUFFIX = "::See Also::TOC" - OUTLINE_SUFFIX = "::See Also::Outline" + DEFAULT = "Evernote" + TOC_SUFFIX = "::See Also::TOC" + OUTLINE_SUFFIX = "::See Also::Outline" + class ANKI: - PROFILE_NAME = '' - NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING = False + PROFILE_NAME = '' + NOTE_LIGHT_PROCESSING_INCLUDE_CSS_FORMATTING = False + class TAGS: - TOC = '#TOC' - AUTO_TOC = '#TOC.Auto' - OUTLINE = '#Outline' - OUTLINE_TESTABLE = '#Outline.Testable' - REVERSIBLE = '#Reversible' - REVERSE_ONLY = '#Reversible_Only' + TOC = '#TOC' + AUTO_TOC = '#TOC.Auto' + OUTLINE = '#Outline' + OUTLINE_TESTABLE = '#Outline.Testable' + REVERSIBLE = '#Reversible' + REVERSE_ONLY = '#Reversible_Only' + class EVERNOTE: - class IMPORT: - class PAGING: - # Note that Evernote's API documentation says not to run API calls to findNoteMetadata with any less than a 15 minute interval - # Auto Paging is probably only useful in the first 24 hours, when API usage is unlimited, or when executing a search that is likely to have most of the notes up-to-date locally - # To keep from overloading Evernote's servers, and flagging our API key, I recommend pausing 5-15 minutes in between searches, the higher the better. - class RESTART: - DELAY_MINIMUM_API_CALLS = 10 - INTERVAL_OVERRIDE = 60*5 - ENABLED = False - INTERVAL = 60 * 15 - INTERVAL_SANDBOX = 60 * 5 - RESTART.INTERVAL = INTERVAL * 2 - INTERVAL = PAGING.INTERVAL * 4 / 3 - METADATA_RESULTS_LIMIT = 10000 - QUERY_LIMIT = 250 # Max returned by API is 250 - API_CALLS_LIMIT = 300 - class UPLOAD: - ENABLED = True # Set False if debugging note creation - MAX = -1 # Set to -1 for unlimited - RESTART_INTERVAL = 30 # In seconds - class VALIDATION: - ENABLED = True - AUTOMATED = True - class API: - class RateLimitErrorHandling: - IgnoreError, ToolTipError, AlertError = range(3) - CONSUMER_KEY = "holycrepe" - IS_SANDBOXED = False - EDAM_RATE_LIMIT_ERROR_HANDLING = RateLimitErrorHandling.ToolTipError - DEBUG_RAISE_ERRORS = False + class IMPORT: + class PAGING: + # Note that Evernote's API documentation says not to run API calls to findNoteMetadata with any less than a 15 minute interval + # Auto Paging is probably only useful in the first 24 hours, when API usage is unlimited, or when executing a search that is likely to have most of the notes up-to-date locally + # To keep from overloading Evernote's servers, and flagging our API key, I recommend pausing 5-15 minutes in between searches, the higher the better. + class RESTART: + DELAY_MINIMUM_API_CALLS = 10 + INTERVAL_OVERRIDE = 60 * 5 + ENABLED = False + + INTERVAL = 60 * 15 + INTERVAL_SANDBOX = 60 * 5 + RESTART.INTERVAL = INTERVAL * 2 + + INTERVAL = PAGING.INTERVAL * 4 / 3 + METADATA_RESULTS_LIMIT = 10000 + QUERY_LIMIT = 250 # Max returned by API is 250 + API_CALLS_LIMIT = 300 + + class UPLOAD: + ENABLED = True # Set False if debugging note creation + MAX = -1 # Set to -1 for unlimited + RESTART_INTERVAL = 30 # In seconds + + class VALIDATION: + ENABLED = True + AUTOMATED = True + + class API: + class RateLimitErrorHandling: + IgnoreError, ToolTipError, AlertError = range(3) + + CONSUMER_KEY = "holycrepe" + IS_SANDBOXED = False + EDAM_RATE_LIMIT_ERROR_HANDLING = RateLimitErrorHandling.ToolTipError + DEBUG_RAISE_ERRORS = False + + class TABLES: - SEE_ALSO = "anknotes_see_also" - NOTE_VALIDATION_QUEUE = "anknotes_note_validation_queue" - AUTO_TOC = u'anknotes_auto_toc' - class EVERNOTE: - NOTEBOOKS = "anknotes_evernote_notebooks" - TAGS = "anknotes_evernote_tags" - NOTES = u'anknotes_evernote_notes' - NOTES_HISTORY = u'anknotes_evernote_notes_history' + SEE_ALSO = "anknotes_see_also" + NOTE_VALIDATION_QUEUE = "anknotes_note_validation_queue" + AUTO_TOC = u'anknotes_auto_toc' + + class EVERNOTE: + NOTEBOOKS = "anknotes_evernote_notebooks" + TAGS = "anknotes_evernote_tags" + NOTES = u'anknotes_evernote_notes' + NOTES_HISTORY = u'anknotes_evernote_notes_history' + class SETTINGS: - class EVERNOTE: - class QUERY: - TAGS_DEFAULT_VALUE = "#Anki_Import" - TAGS = 'anknotesEvernoteQueryTags' - USE_TAGS = 'anknotesEvernoteQueryUseTags' - EXCLUDED_TAGS = 'anknotesEvernoteQueryExcludedTags' - USE_EXCLUDED_TAGS = 'anknotesEvernoteQueryUseExcludedTags' - LAST_UPDATED_VALUE_RELATIVE = 'anknotesEvernoteQueryLastUpdatedValueRelative' - LAST_UPDATED_VALUE_ABSOLUTE_DATE = 'anknotesEvernoteQueryLastUpdatedValueAbsoluteDate' - LAST_UPDATED_VALUE_ABSOLUTE_TIME = 'anknotesEvernoteQueryLastUpdatedValueAbsoluteDateTime' - LAST_UPDATED_TYPE = 'anknotesEvernoteQueryLastUpdatedType' - USE_LAST_UPDATED = 'anknotesEvernoteQueryUseLastUpdated' - NOTEBOOK = 'anknotesEvernoteQueryNotebook' - NOTEBOOK_DEFAULT_VALUE = 'My Anki Notebook' - USE_NOTEBOOK = 'anknotesEvernoteQueryUseNotebook' - NOTE_TITLE = 'anknotesEvernoteQueryNoteTitle' - USE_NOTE_TITLE = 'anknotesEvernoteQueryUseNoteTitle' - SEARCH_TERMS = 'anknotesEvernoteQuerySearchTerms' - USE_SEARCH_TERMS = 'anknotesEvernoteQueryUseSearchTerms' - ANY = 'anknotesEvernoteQueryAny' - class ACCOUNT: - UID = 'ankNotesEvernoteAccountUID' - SHARD = 'ankNotesEvernoteAccountSHARD' - UID_DEFAULT_VALUE = '0' - SHARD_DEFAULT_VALUE = 'x999' - LAST_IMPORT = "ankNotesEvernoteLastAutoImport" - PAGINATION_CURRENT_PAGE = 'anknotesEvernotePaginationCurrentPage' - AUTO_PAGING = 'anknotesEvernoteAutoPaging' - AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + EVERNOTE.API.CONSUMER_KEY + ( - "_SANDBOX" if EVERNOTE.API.IS_SANDBOXED else "") - class ANKI: - class DECKS: - EVERNOTE_NOTEBOOK_INTEGRATION = 'anknotesUseNotebookNameForAnkiDeckName' - BASE = 'anknotesDefaultAnkiDeck' - BASE_DEFAULT_VALUE = DECKS.DEFAULT - class TAGS: - TO_DELETE = 'anknotesTagsToDelete' - KEEP_TAGS_DEFAULT_VALUE = True - KEEP_TAGS = 'anknotesTagsKeepEvernoteTags' - DELETE_EVERNOTE_QUERY_TAGS = 'anknotesTagsDeleteEvernoteQueryTags' - UPDATE_EXISTING_NOTES = 'anknotesUpdateExistingNotes' - ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX = "ankNotesCheckableMenuItems" + class EVERNOTE: + class QUERY: + TAGS_DEFAULT_VALUE = "#Anki_Import" + TAGS = 'anknotesEvernoteQueryTags' + USE_TAGS = 'anknotesEvernoteQueryUseTags' + EXCLUDED_TAGS = 'anknotesEvernoteQueryExcludedTags' + USE_EXCLUDED_TAGS = 'anknotesEvernoteQueryUseExcludedTags' + LAST_UPDATED_VALUE_RELATIVE = 'anknotesEvernoteQueryLastUpdatedValueRelative' + LAST_UPDATED_VALUE_ABSOLUTE_DATE = 'anknotesEvernoteQueryLastUpdatedValueAbsoluteDate' + LAST_UPDATED_VALUE_ABSOLUTE_TIME = 'anknotesEvernoteQueryLastUpdatedValueAbsoluteDateTime' + LAST_UPDATED_TYPE = 'anknotesEvernoteQueryLastUpdatedType' + USE_LAST_UPDATED = 'anknotesEvernoteQueryUseLastUpdated' + NOTEBOOK = 'anknotesEvernoteQueryNotebook' + NOTEBOOK_DEFAULT_VALUE = 'My Anki Notebook' + USE_NOTEBOOK = 'anknotesEvernoteQueryUseNotebook' + NOTE_TITLE = 'anknotesEvernoteQueryNoteTitle' + USE_NOTE_TITLE = 'anknotesEvernoteQueryUseNoteTitle' + SEARCH_TERMS = 'anknotesEvernoteQuerySearchTerms' + USE_SEARCH_TERMS = 'anknotesEvernoteQueryUseSearchTerms' + ANY = 'anknotesEvernoteQueryAny' + + class ACCOUNT: + UID = 'ankNotesEvernoteAccountUID' + SHARD = 'ankNotesEvernoteAccountSHARD' + UID_DEFAULT_VALUE = '0' + SHARD_DEFAULT_VALUE = 'x999' + + LAST_IMPORT = "ankNotesEvernoteLastAutoImport" + PAGINATION_CURRENT_PAGE = 'anknotesEvernotePaginationCurrentPage' + AUTO_PAGING = 'anknotesEvernoteAutoPaging' + AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + EVERNOTE.API.CONSUMER_KEY + ( + "_SANDBOX" if EVERNOTE.API.IS_SANDBOXED else "") + + class ANKI: + class DECKS: + EVERNOTE_NOTEBOOK_INTEGRATION = 'anknotesUseNotebookNameForAnkiDeckName' + BASE = 'anknotesDefaultAnkiDeck' + BASE_DEFAULT_VALUE = DECKS.DEFAULT + + class TAGS: + TO_DELETE = 'anknotesTagsToDelete' + KEEP_TAGS_DEFAULT_VALUE = True + KEEP_TAGS = 'anknotesTagsKeepEvernoteTags' + DELETE_EVERNOTE_QUERY_TAGS = 'anknotesTagsDeleteEvernoteQueryTags' + + UPDATE_EXISTING_NOTES = 'anknotesUpdateExistingNotes' + + ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX = "ankNotesCheckableMenuItems" # Allow user-defined options; place at end of document so that user-defined options override diff --git a/anknotes/constants_user.py b/anknotes/constants_user.py index 288c37f..1279357 100644 --- a/anknotes/constants_user.py +++ b/anknotes/constants_user.py @@ -4,15 +4,25 @@ # PREFIX ALL SETTINGS WITH THE constants MODULE REFERENCE AS SHOWN BELOW: # DON'T FORGET TO REGENERATE ANY VARIABLES THAT DERIVE FROM THE ONES YOU ARE CHANGING -try: from anknotes import constants -except: - import os - import imp - path = os.path.dirname(__file__) - name = 'constants' - modfile, modpath, description = imp.find_module(name, [path + '\\']) - constants=imp.load_module(name, modfile, modpath, description) - modfile.close() +try: + from anknotes import constants +except ImportError: + import os + import imp -# constants.EVERNOTE.API.IS_SANDBOXED = True -# constants.SETTINGS.EVERNOTE.AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + constants.EVERNOTE.API.CONSUMER_KEY + ("_SANDBOX" if constants.EVERNOTE.API.IS_SANDBOXED else "") \ No newline at end of file + path = os.path.dirname(__file__) + name = 'constants' + + try: + + modfile, modpath, description = imp.find_module(name, [path + '\\']) + print(imp.find_module(name, [path + '\\'])) + constants = imp.load_module(name, modfile, modpath, description) + modfile.close() + except ImportError as e: + print(path) + print(e) + + + # constants.EVERNOTE.API.IS_SANDBOXED = True + # constants.SETTINGS.EVERNOTE.AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + constants.EVERNOTE.API.CONSUMER_KEY + ("_SANDBOX" if constants.EVERNOTE.API.IS_SANDBOXED else "") \ No newline at end of file diff --git a/anknotes/counters.py b/anknotes/counters.py index 61dba27..8314bbc 100644 --- a/anknotes/counters.py +++ b/anknotes/counters.py @@ -3,442 +3,506 @@ from pprint import pprint from addict import Dict from anknotes.constants import * -inAnki='anki' in sys.modules + +inAnki = 'anki' in sys.modules + def print_banner(title): - print "-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5) - print title - print "-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5) + print + "-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5) + print + title + print + "-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5) class DictCaseInsensitive(Dict): - def print_banner(self, title): - print self.make_banner(title) - - @staticmethod - def make_banner(title): - return '\n'.join(["-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5), title ,"-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5)]) - - def __process_kwarg__(self, kwargs, key, default=None, replace_none_type=True): - key = self.__key_transform__(key, kwargs.keys()) - if key not in kwargs: return default - val = kwargs[key] - if val is None and replace_none_type: val = default - del kwargs[key] - return val - def __key_transform__(self, key,keys=None): - if keys is None: keys = self.keys() - for k in keys: - if k.lower() == key.lower(): return k - return key - def __init__(self, *args, **kwargs): - # if not isinstance(label, unicode) and not isinstance(label, str): raise TypeError("Cannot create counter label from non-string type: " + str(label)) - # print "kwargs: %s" % (str(kwargs)) - lbl = self.__process_kwarg__(kwargs, 'label', 'root') - parent_lbl = self.__process_kwarg__(kwargs, 'parent_label', '') - # print "lbl: %s\nkwargs: %s" % (lbl, str(kwargs)) - self.__label__ = "root" - self.__parent_label__ = "" - return super(DictCaseInsensitive, self).__init__(*args, **kwargs) - - def reset(self, keys_to_keep=None): - if keys_to_keep is None: keys_to_keep=self.__my_aggregates__.lower().split("|") - for key in self.keys(): - if key.lower() not in keys_to_keep: del self[key] - - __label__ = '' - __parent_label__ = '' - __my_aggregates__ = '' - __my_attrs__ = '__label__|__parent_label__|__my_aggregates__' - @property - def label(self): return self.__label__ - - @property - def parent_label(self): return self.__parent_label__ - - @property - def full_label(self): return self.parent_label + ('.' if self.parent_label else '') + self.label - - def __setattr__(self, key, value): - key_adj = self.__key_transform__(key) - if key[0:1] + key[-1:] == '__': - if key.lower() not in self.__my_attrs__.lower().split('|'): - raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) - else: super(Dict, self).__setattr__(key, value) - # elif key == 'Count': - # self.setCount(value) - # # super(CaseInsensitiveDict, self).__setattr__(key, value) - # setattr(self, 'Count', value) - elif (hasattr(self, key)): - # print "Setting key " + key + ' value... to ' + str(value) - self[key_adj] = value - else: - print "Setting attr %s to type %s value %s" % (key_adj, type(value), value) - super(Dict, self).__setitem__(key_adj, value) - - def __setitem__(self, name, value): - # print "Setting item %s to type %s value %s" % (name, type(value), value) - super(Dict, self).__setitem__(name, value) - - def __getitem__(self, key): - adjkey = self.__key_transform__(key) - if adjkey not in self: - if key[0:1] + key[-1:] == '__': - if key.lower() not in self.__my_attrs__.lower().split('|'): - try: - return super(Dict, self).__getattr__(key.lower()) - except: - raise(KeyError("Could not find protected item " + key)) - return super(DictCaseInsensitive, self).__getattr__(key.lower()) - # print "Creating missing item: " + self.parent_label + ('.' if self.parent_label else '') + self.label + ' -> ' + repr(adjkey) - self[adjkey] = DictCaseInsensitive() - self[adjkey].__label__ = adjkey - self[adjkey].__parent_label__ = self.full_label - try: - return super(DictCaseInsensitive, self).__getitem__(adjkey) - except TypeError: - return "<null>" + def print_banner(self, title): + print + self.make_banner(title) + + @staticmethod + def make_banner(title): + return '\n'.join(["-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5), title, + "-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5)]) + + def __process_kwarg__(self, kwargs, key, default=None, replace_none_type=True): + key = self.__key_transform__(key, kwargs.keys()) + if key not in kwargs: return default + val = kwargs[key] + if val is None and replace_none_type: val = default + del kwargs[key] + return val + + def __key_transform__(self, key, keys=None): + if keys is None: keys = self.keys() + for k in keys: + if k.lower() == key.lower(): return k + return key + + def __init__(self, *args, **kwargs): + # if not isinstance(label, unicode) and not isinstance(label, str): raise TypeError("Cannot create counter label from non-string type: " + str(label)) + # print "kwargs: %s" % (str(kwargs)) + lbl = self.__process_kwarg__(kwargs, 'label', 'root') + parent_lbl = self.__process_kwarg__(kwargs, 'parent_label', '') + # print "lbl: %s\nkwargs: %s" % (lbl, str(kwargs)) + self.__label__ = "root" + self.__parent_label__ = "" + return super(DictCaseInsensitive, self).__init__(*args, **kwargs) + + def reset(self, keys_to_keep=None): + if keys_to_keep is None: keys_to_keep = self.__my_aggregates__.lower().split("|") + for key in self.keys(): + if key.lower() not in keys_to_keep: del self[key] + + __label__ = '' + __parent_label__ = '' + __my_aggregates__ = '' + __my_attrs__ = '__label__|__parent_label__|__my_aggregates__' + + @property + def label(self): + return self.__label__ + + @property + def parent_label(self): + return self.__parent_label__ + + @property + def full_label(self): + return self.parent_label + ('.' if self.parent_label else '') + self.label + + def __setattr__(self, key, value): + key_adj = self.__key_transform__(key) + if key[0:1] + key[-1:] == '__': + if key.lower() not in self.__my_attrs__.lower().split('|'): + raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) + else: + super(Dict, self).__setattr__(key, value) + # elif key == 'Count': + # self.setCount(value) + # # super(CaseInsensitiveDict, self).__setattr__(key, value) + # setattr(self, 'Count', value) + elif (hasattr(self, key)): + # print "Setting key " + key + ' value... to ' + str(value) + self[key_adj] = value + else: + print + "Setting attr %s to type %s value %s" % (key_adj, type(value), value) + super(Dict, self).__setitem__(key_adj, value) + + def __setitem__(self, name, value): + # print "Setting item %s to type %s value %s" % (name, type(value), value) + super(Dict, self).__setitem__(name, value) + + def __getitem__(self, key): + adjkey = self.__key_transform__(key) + if adjkey not in self: + if key[0:1] + key[-1:] == '__': + if key.lower() not in self.__my_attrs__.lower().split('|'): + try: + return super(Dict, self).__getattr__(key.lower()) + except: + raise (KeyError("Could not find protected item " + key)) + return super(DictCaseInsensitive, self).__getattr__(key.lower()) + # print "Creating missing item: " + self.parent_label + ('.' if self.parent_label else '') + self.label + ' -> ' + repr(adjkey) + self[adjkey] = DictCaseInsensitive() + self[adjkey].__label__ = adjkey + self[adjkey].__parent_label__ = self.full_label + try: + return super(DictCaseInsensitive, self).__getitem__(adjkey) + except TypeError: + return "<null>" + class DictCaseInsensitive2(Dict): - __label__ = '' - __parent_label__ = '' - __my_aggregates__ = '' - __my_attrs__ = '__label__|__parent_label__' - def __process_kwarg__(self, kwargs, key, default=None): - key = self.__key_transform__(key, kwargs.keys()) - if key not in kwargs: return default - val = kwargs[key] - del kwargs[key] - return val - def __key_transform__(self, key,keys=None): - if keys is None: keys = self.keys() - for k in keys: - if k.lower() == key.lower(): return k - return key - def __init__(self, *args, **kwargs): - print "kwargs: %s" % (str(kwargs)) - lbl = self.__process_kwarg__(kwargs, 'label', 'root') - parent_lbl = self.__process_kwarg__(kwargs, 'parent_label', '') - print "lbl: %s\nkwargs: %s" % (lbl, str(kwargs)) - if not isinstance(lbl, unicode) and not isinstance(lbl, str): raise TypeError("Cannot create DictCaseInsensitive label from non-string type: " + str(lbl)) - if not isinstance(parent_lbl, unicode) and not isinstance(parent_lbl, str): raise TypeError("Cannot create DictCaseInsensitive parent label from non-string type: " + str(parent_lbl)) - self.__label__ = lbl - self.__parent_label__ = parent_lbl - # return super(DictCaseInsensitive, self).__init__(*args, **kwargs) - - - def __setattr__(self, key, value): - key_adj = self.__key_transform__(key) - if key[0:1] + key[-1:] == '__': - if key.lower() not in self.__my_attrs__.lower().split('|'): - raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) - super(Dict, self).__setattr__(key, value) - else: - print "Setting attr %s to type %s value %s" % (key_adj, type(value), value) - super(Dict, self).__setitem__(key_adj, value) - - def __setitem__(self, name, value): - super(Dict, self).__setitem__(name, value) - - def __getitem__(self, key): - adjkey = self.__key_transform__(key) - if adjkey not in self: - if key[0:1] + key[-1:] == '__': - if key.lower() not in self.__my_attrs__.lower().split('|'): - try: return super(Dict, self).__getattr__(key.lower()) - except: raise(KeyError("Could not find protected item " + key)) - return super(Dict, self).__getattr__(key.lower()) - self[adjkey] = DictCaseInsensitive(label=adjkey,parent_label=self.full_label) - try: return super(Dict, self).__getitem__(adjkey) - except TypeError: return "<null>" + __label__ = '' + __parent_label__ = '' + __my_aggregates__ = '' + __my_attrs__ = '__label__|__parent_label__' + + def __process_kwarg__(self, kwargs, key, default=None): + key = self.__key_transform__(key, kwargs.keys()) + if key not in kwargs: return default + val = kwargs[key] + del kwargs[key] + return val + + def __key_transform__(self, key, keys=None): + if keys is None: keys = self.keys() + for k in keys: + if k.lower() == key.lower(): return k + return key + + def __init__(self, *args, **kwargs): + print + "kwargs: %s" % (str(kwargs)) + lbl = self.__process_kwarg__(kwargs, 'label', 'root') + parent_lbl = self.__process_kwarg__(kwargs, 'parent_label', '') + print + "lbl: %s\nkwargs: %s" % (lbl, str(kwargs)) + if not isinstance(lbl, unicode) and not isinstance(lbl, str): raise TypeError( + "Cannot create DictCaseInsensitive label from non-string type: " + str(lbl)) + if not isinstance(parent_lbl, unicode) and not isinstance(parent_lbl, str): raise TypeError( + "Cannot create DictCaseInsensitive parent label from non-string type: " + str(parent_lbl)) + self.__label__ = lbl + self.__parent_label__ = parent_lbl + + # return super(DictCaseInsensitive, self).__init__(*args, **kwargs) + + + def __setattr__(self, key, value): + key_adj = self.__key_transform__(key) + if key[0:1] + key[-1:] == '__': + if key.lower() not in self.__my_attrs__.lower().split('|'): + raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) + super(Dict, self).__setattr__(key, value) + else: + print + "Setting attr %s to type %s value %s" % (key_adj, type(value), value) + super(Dict, self).__setitem__(key_adj, value) + + def __setitem__(self, name, value): + super(Dict, self).__setitem__(name, value) + + def __getitem__(self, key): + adjkey = self.__key_transform__(key) + if adjkey not in self: + if key[0:1] + key[-1:] == '__': + if key.lower() not in self.__my_attrs__.lower().split('|'): + try: + return super(Dict, self).__getattr__(key.lower()) + except: + raise (KeyError("Could not find protected item " + key)) + return super(Dict, self).__getattr__(key.lower()) + self[adjkey] = DictCaseInsensitive(label=adjkey, parent_label=self.full_label) + try: + return super(Dict, self).__getitem__(adjkey) + except TypeError: + return "<null>" + class Counter(Dict): - def print_banner(self, title): - print self.make_banner(title) - - @staticmethod - def make_banner(title): - return '\n'.join(["-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5), title ,"-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5)]) - - def __init__(self, *args, **kwargs): - self.setCount(0) - # if not isinstance(label, unicode) and not isinstance(label, str): raise TypeError("Cannot create counter label from non-string type: " + str(label)) - self.__label__ = "root" - self.__parent_label__ = "" - self.__is_exclusive_sum__ = True - # return super(Counter, self).__init__(*args, **kwargs) - - def reset(self, keys_to_keep=None): - if keys_to_keep is None: keys_to_keep=self.__my_aggregates__.lower().split("|") - for key in self.keys(): - if key.lower() not in keys_to_keep: del self[key] - - def __key_transform__(self, key): - for k in self.keys(): - if k.lower() == key.lower(): return k - return key - # return key[0].upper() + key[1:].lower() - - __count__ = 0 - __label__ = '' - __parent_label__ = '' - __is_exclusive_sum__ = False - __my_aggregates__ = 'max|max_allowed' - __my_attrs__ = '__count__|__is_exclusive_sum__|__label__|__parent_label__|__my_aggregates__' - def getCount(self): - if self.__is_exclusive_sum__: return self.sum - return self.__count__ - - def setCount(self, value): - self.__is_exclusive_sum__ = False - self.__count__ = value - - @property - def label(self): return self.__label__ - - @property - def parent_label(self): return self.__parent_label__ - - @property - def full_label(self): return self.parent_label + ('.' if self.parent_label else '') + self.label - - @property - def get(self): - return self.getCount() - - val = value = cnt = count = get - - @property - def sum(self): - # self.print_banner("Getting main Count ") - sum = 0 - for key in self.iterkeys(): - if key in self.__my_aggregates__.split("|"): continue - val = self[key] - if isinstance(val, int): - sum += val - elif isinstance(val, Counter) or isinstance(val, EvernoteCounter): - sum += val.getCount() - # print 'sum: ' + key + ': - ' + str(val) + ' ~ ' + str(sum) - return sum - - def increment(self, y=1, negate=False): - newCount = self.__sub__(y) if negate else self.__add__(y) - # print "Incrementing %s by %d to %d" % (self.full_label, y, newCount) - self.setCount(newCount) - return newCount - - step = increment - - def __coerce__(self, y): return (self.getCount(), y) - - def __div__(self, y): - return self.getCount() / y - - def __rdiv__(self, y): - return y / self.getCount() - - __truediv__ = __div__ - - def __mul__(self, y): return y * self.getCount() - __rmul__ = __mul__ - - def __sub__(self, y): - return self.getCount() - y - # return self.__add__(y, negate=True) - - def __add__(self, y, negate=False): - # if isinstance(y, Counter): - # print "y=getCount: %s" % str(y) - # y = y.getCount() - return self.getCount() + y - # * (-1 if negate else 1) - - __radd__ = __add__ - - def __rsub__(self, y, negate=False): - return y - self.getCount() - - def __iadd__(self, y): - self.increment(y) - - def __isub__(self, y): - self.increment(y, negate=True) - def __truth__(self): - print "truth" - return True - def __bool__(self): - return self.getCount() > 0 - - __nonzero__ = __bool__ - - def __setattr__(self, key, value): - key_adj = self.__key_transform__(key) - if key[0:1] + key[-1:] == '__': - if key.lower() not in self.__my_attrs__.lower().split('|'): - raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) - else: super(Dict, self).__setattr__(key, value) - elif key == 'Count': - self.setCount(value) - # super(CaseInsensitiveDict, self).__setattr__(key, value) - # setattr(self, 'Count', value) - elif (hasattr(self, key)): - # print "Setting key " + key + ' value... to ' + str(value) - self[key_adj].setCount(value) - else: - print "Setting attr %s to type %s value %s" % (key_adj, type(value), value) - super(Dict, self).__setitem__(key_adj, value) - - def __setitem__(self, name, value): - # print "Setting item %s to type %s value %s" % (name, type(value), value) - super(Dict, self).__setitem__(name, value) - - def __get_summary__(self,level=1,header_only=False): - keys=self.keys() - counts=[Dict(level=level,label=self.label,full_label=self.full_label,value=self.getCount(),is_exclusive_sum=self.__is_exclusive_sum__,class_name=self.__class__.__name__,children=keys)] - if header_only: return counts - for key in keys: - # print "Summaryzing key %s: %s " % (key, type( self[key])) - if key not in self.__my_aggregates__.split("|"): - counts += self[key].__get_summary__(level+1) - return counts - def __summarize_lines__(self, summary,header=True): - lines=[] - for i, item in enumerate(summary): - exclusive_sum_marker = '*' if item.is_exclusive_sum and len(item.children) > 0 else ' ' - if i is 0 and header: - lines.append("<%s%s:%s:%d>" % (exclusive_sum_marker.strip(), item.class_name, item.full_label, item.value)) - continue - # strr = '%s%d' % (exclusive_sum_marker, item.value) - strr = (' ' * (item.level * 2 - 1) + exclusive_sum_marker + item.label + ':').ljust(16+item.level*2) - lines.append(strr+' ' + str(item.value).rjust(3) + exclusive_sum_marker) - return '\n'.join(lines) - def __repr__(self): - return self.__summarize_lines__(self.__get_summary__()) - - def __getitem__(self, key): - adjkey = self.__key_transform__(key) - if key == 'Count': return self.getCount() - if adjkey not in self: - if key[0:1] + key[-1:] == '__': - if key.lower() not in self.__my_attrs__.lower().split('|'): - try: - return super(Dict, self).__getattr__(key.lower()) - except: - raise(KeyError("Could not find protected item " + key)) - return super(Counter, self).__getattr__(key.lower()) - # print "Creating missing item: " + self.parent_label + ('.' if self.parent_label else '') + self.label + ' -> ' + repr(adjkey) - self[adjkey] = Counter(adjkey) - self[adjkey].__label__ = adjkey - self[adjkey].__parent_label__ = self.full_label - self[adjkey].__is_exclusive_sum__ = True - try: - return super(Counter, self).__getitem__(adjkey) - except TypeError: - return "<null>" - # print "Unexpected type of self in __getitem__: " + str(type(self)) - # raise TypeError - # except: - # raise + def print_banner(self, title): + print + self.make_banner(title) + + @staticmethod + def make_banner(title): + return '\n'.join(["-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5), title, + "-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5)]) + + def __init__(self, *args, **kwargs): + self.setCount(0) + # if not isinstance(label, unicode) and not isinstance(label, str): raise TypeError("Cannot create counter label from non-string type: " + str(label)) + self.__label__ = "root" + self.__parent_label__ = "" + self.__is_exclusive_sum__ = True + + # return super(Counter, self).__init__(*args, **kwargs) + + def reset(self, keys_to_keep=None): + if keys_to_keep is None: keys_to_keep = self.__my_aggregates__.lower().split("|") + for key in self.keys(): + if key.lower() not in keys_to_keep: del self[key] + + def __key_transform__(self, key): + for k in self.keys(): + if k.lower() == key.lower(): return k + return key + + # return key[0].upper() + key[1:].lower() + + __count__ = 0 + __label__ = '' + __parent_label__ = '' + __is_exclusive_sum__ = False + __my_aggregates__ = 'max|max_allowed' + __my_attrs__ = '__count__|__is_exclusive_sum__|__label__|__parent_label__|__my_aggregates__' + + def getCount(self): + if self.__is_exclusive_sum__: return self.sum + return self.__count__ + + def setCount(self, value): + self.__is_exclusive_sum__ = False + self.__count__ = value + + @property + def label(self): + return self.__label__ + + @property + def parent_label(self): + return self.__parent_label__ + + @property + def full_label(self): + return self.parent_label + ('.' if self.parent_label else '') + self.label + + @property + def get(self): + return self.getCount() + + val = value = cnt = count = get + + @property + def sum(self): + # self.print_banner("Getting main Count ") + sum = 0 + for key in self.iterkeys(): + if key in self.__my_aggregates__.split("|"): continue + val = self[key] + if isinstance(val, int): + sum += val + elif isinstance(val, Counter) or isinstance(val, EvernoteCounter): + sum += val.getCount() + # print 'sum: ' + key + ': - ' + str(val) + ' ~ ' + str(sum) + return sum + + def increment(self, y=1, negate=False): + newCount = self.__sub__(y) if negate else self.__add__(y) + # print "Incrementing %s by %d to %d" % (self.full_label, y, newCount) + self.setCount(newCount) + return newCount + + step = increment + + def __coerce__(self, y): + return (self.getCount(), y) + + def __div__(self, y): + return self.getCount() / y + + def __rdiv__(self, y): + return y / self.getCount() + + __truediv__ = __div__ + + def __mul__(self, y): + return y * self.getCount() + + __rmul__ = __mul__ + + def __sub__(self, y): + return self.getCount() - y + + # return self.__add__(y, negate=True) + + def __add__(self, y, negate=False): + # if isinstance(y, Counter): + # print "y=getCount: %s" % str(y) + # y = y.getCount() + return self.getCount() + y + + # * (-1 if negate else 1) + + __radd__ = __add__ + + def __rsub__(self, y, negate=False): + return y - self.getCount() + + def __iadd__(self, y): + self.increment(y) + + def __isub__(self, y): + self.increment(y, negate=True) + + def __truth__(self): + print + "truth" + return True + + def __bool__(self): + return self.getCount() > 0 + + __nonzero__ = __bool__ + + def __setattr__(self, key, value): + key_adj = self.__key_transform__(key) + if key[0:1] + key[-1:] == '__': + if key.lower() not in self.__my_attrs__.lower().split('|'): + raise AttributeError("Attempted to set protected item %s on %s" % (key, self.__class__.__name__)) + else: + super(Dict, self).__setattr__(key, value) + elif key == 'Count': + self.setCount(value) + # super(CaseInsensitiveDict, self).__setattr__(key, value) + # setattr(self, 'Count', value) + elif (hasattr(self, key)): + # print "Setting key " + key + ' value... to ' + str(value) + self[key_adj].setCount(value) + else: + print + "Setting attr %s to type %s value %s" % (key_adj, type(value), value) + super(Dict, self).__setitem__(key_adj, value) + + def __setitem__(self, name, value): + # print "Setting item %s to type %s value %s" % (name, type(value), value) + super(Dict, self).__setitem__(name, value) + + def __get_summary__(self, level=1, header_only=False): + keys = self.keys() + counts = [Dict(level=level, label=self.label, full_label=self.full_label, value=self.getCount(), + is_exclusive_sum=self.__is_exclusive_sum__, class_name=self.__class__.__name__, children=keys)] + if header_only: return counts + for key in keys: + # print "Summaryzing key %s: %s " % (key, type( self[key])) + if key not in self.__my_aggregates__.split("|"): + counts += self[key].__get_summary__(level + 1) + return counts + + def __summarize_lines__(self, summary, header=True): + lines = [] + for i, item in enumerate(summary): + exclusive_sum_marker = '*' if item.is_exclusive_sum and len(item.children) > 0 else ' ' + if i is 0 and header: + lines.append( + "<%s%s:%s:%d>" % (exclusive_sum_marker.strip(), item.class_name, item.full_label, item.value)) + continue + # strr = '%s%d' % (exclusive_sum_marker, item.value) + strr = (' ' * (item.level * 2 - 1) + exclusive_sum_marker + item.label + ':').ljust(16 + item.level * 2) + lines.append(strr + ' ' + str(item.value).rjust(3) + exclusive_sum_marker) + return '\n'.join(lines) + + def __repr__(self): + return self.__summarize_lines__(self.__get_summary__()) + + def __getitem__(self, key): + adjkey = self.__key_transform__(key) + if key == 'Count': return self.getCount() + if adjkey not in self: + if key[0:1] + key[-1:] == '__': + if key.lower() not in self.__my_attrs__.lower().split('|'): + try: + return super(Dict, self).__getattr__(key.lower()) + except: + raise (KeyError("Could not find protected item " + key)) + return super(Counter, self).__getattr__(key.lower()) + # print "Creating missing item: " + self.parent_label + ('.' if self.parent_label else '') + self.label + ' -> ' + repr(adjkey) + self[adjkey] = Counter(adjkey) + self[adjkey].__label__ = adjkey + self[adjkey].__parent_label__ = self.full_label + self[adjkey].__is_exclusive_sum__ = True + try: + return super(Counter, self).__getitem__(adjkey) + except TypeError: + return "<null>" + # print "Unexpected type of self in __getitem__: " + str(type(self)) + # raise TypeError + # except: + # raise + + class EvernoteCounter(Counter): - @property - def success(self): - return self.created + self.updated - - @property - def queued(self): - return self.created.queued + self.updated.queued - - @property - def completed(self): - return self.created.completed + self.updated.completed - - @property - def delayed(self): - return self.skipped + self.queued - @property - def total(self): - return self.getCount() #- self.max - self.max_allowed - - def aggregateSummary(self, includeHeader=True): - aggs = '!max|!+max_allowed|total|+success|++completed|++queued|+delayed' - counts=self.__get_summary__(header_only=True) if includeHeader else [] - parents = [] - last_level=1 - for key_code in aggs.split('|'): - is_exclusive_sum = key_code[0] is not '!' - if not is_exclusive_sum: key_code = key_code[1:] - key = key_code.lstrip('+') - level = len(key_code) - len(key) + 1 - val = self.__getattr__(key) - cls = type(val) - if cls is not int: val = val.getCount() - parent_lbl = '.'.join(parents) - full_label = parent_lbl + ('.' if parent_lbl else '') + key - counts+=[Dict(level=level,label=key,full_label=full_label,value=val,is_exclusive_sum=is_exclusive_sum,class_name=cls,children=['<aggregate>'])] - if level < last_level: del parents[-1] - elif level > last_level: parents.append(key) - last_level = level - return self.__summarize_lines__(counts,includeHeader) - - def fullSummary(self, title='Evernote Counter'): - return '\n'.join( - [self.make_banner(title + ": Summary"), - self.__repr__(), - ' ', - self.make_banner(title + ": Aggregates"), - self.aggregateSummary(False)] - ) - - def __getattr__(self, key): - if hasattr(self, key) and key not in self.keys(): - return getattr(self, key) - return super(EvernoteCounter, self).__getattr__(key) - - def __getitem__(self, key): - # print 'getitem: ' + key - return super(EvernoteCounter, self).__getitem__(key) + @property + def success(self): + return self.created + self.updated + + @property + def queued(self): + return self.created.queued + self.updated.queued + + @property + def completed(self): + return self.created.completed + self.updated.completed + + @property + def delayed(self): + return self.skipped + self.queued + + @property + def total(self): + return self.getCount() # - self.max - self.max_allowed + + def aggregateSummary(self, includeHeader=True): + aggs = '!max|!+max_allowed|total|+success|++completed|++queued|+delayed' + counts = self.__get_summary__(header_only=True) if includeHeader else [] + parents = [] + last_level = 1 + for key_code in aggs.split('|'): + is_exclusive_sum = key_code[0] is not '!' + if not is_exclusive_sum: key_code = key_code[1:] + key = key_code.lstrip('+') + level = len(key_code) - len(key) + 1 + val = self.__getattr__(key) + cls = type(val) + if cls is not int: val = val.getCount() + parent_lbl = '.'.join(parents) + full_label = parent_lbl + ('.' if parent_lbl else '') + key + counts += [Dict(level=level, label=key, full_label=full_label, value=val, is_exclusive_sum=is_exclusive_sum, + class_name=cls, children=['<aggregate>'])] + if level < last_level: + del parents[-1] + elif level > last_level: + parents.append(key) + last_level = level + return self.__summarize_lines__(counts, includeHeader) + + def fullSummary(self, title='Evernote Counter'): + return '\n'.join( + [self.make_banner(title + ": Summary"), + self.__repr__(), + ' ', + self.make_banner(title + ": Aggregates"), + self.aggregateSummary(False)] + ) + + def __getattr__(self, key): + if hasattr(self, key) and key not in self.keys(): + return getattr(self, key) + return super(EvernoteCounter, self).__getattr__(key) + + def __getitem__(self, key): + # print 'getitem: ' + key + return super(EvernoteCounter, self).__getitem__(key) + from pprint import pprint + def test(): - global Counts - absolutely_unused_variable = os.system("cls") - del absolutely_unused_variable - Counts = EvernoteCounter() - Counts.unhandled.step(5) - Counts.skipped.step(3) - Counts.error.step() - Counts.updated.completed.step(9) - Counts.created.completed.step(9) - Counts.created.completed.subcount.step(3) - # Counts.updated.completed.subcount = 0 - Counts.created.queued.step() - Counts.updated.queued.step(3) - Counts.max = 150 - Counts.max_allowed = -1 - Counts.print_banner("Evernote Counter: Summary") - print (Counts) - Counts.print_banner("Evernote Counter: Aggregates") - print (Counts.aggregateSummary()) - - Counts.reset() - - print Counts.fullSummary('Reset Counter') - return - - Counts.print_banner("Evernote Counter") - print Counts - Counts.skipped.step(3) - # Counts.updated.completed.step(9) - # Counts.created.completed.step(9) - Counts.print_banner("Evernote Counter") - print Counts - Counts.error.step() - # Counts.updated.queued.step() - # Counts.created.queued.step(7) - Counts.print_banner("Evernote Counter") - # print Counts + global Counts + absolutely_unused_variable = os.system("cls") + del absolutely_unused_variable + Counts = EvernoteCounter() + Counts.unhandled.step(5) + Counts.skipped.step(3) + Counts.error.step() + Counts.updated.completed.step(9) + Counts.created.completed.step(9) + Counts.created.completed.subcount.step(3) + # Counts.updated.completed.subcount = 0 + Counts.created.queued.step() + Counts.updated.queued.step(3) + Counts.max = 150 + Counts.max_allowed = -1 + Counts.print_banner("Evernote Counter: Summary") + print(Counts) + Counts.print_banner("Evernote Counter: Aggregates") + print(Counts.aggregateSummary()) + + Counts.reset() + + print + Counts.fullSummary('Reset Counter') + return + + Counts.print_banner("Evernote Counter") + print + Counts + Counts.skipped.step(3) + # Counts.updated.completed.step(9) + # Counts.created.completed.step(9) + Counts.print_banner("Evernote Counter") + print + Counts + Counts.error.step() + # Counts.updated.queued.step() + # Counts.created.queued.step(7) + Counts.print_banner("Evernote Counter") + +# print Counts # if not inAnki and 'anknotes' not in sys.modules: test() diff --git a/anknotes/db.py b/anknotes/db.py index 104d6fb..56cbf5b 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -7,203 +7,221 @@ from anknotes.constants import * try: - from aqt import mw + from aqt import mw except: - pass + pass ankNotesDBInstance = None dbLocal = False + def anki_profile_path_root(): - return os.path.abspath(os.path.join(os.path.dirname(PATH), '..' + os.path.sep)) + return os.path.abspath(os.path.join(os.path.dirname(PATH), '..' + os.path.sep)) + def last_anki_profile_name(): - root = anki_profile_path_root() - name = ANKI.PROFILE_NAME - if name and os.path.isdir(os.path.join(root, name)): return name - if os.path.isfile(FILES.USER.LAST_PROFILE_LOCATION): - name = file(FILES.USER.LAST_PROFILE_LOCATION, 'r').read().strip() - if name and os.path.isdir(os.path.join(root, name)): return name - dirs = [x for x in os.listdir(root) if os.path.isdir(os.path.join(root, x)) and x is not 'addons'] - if not dirs: return "" - return dirs[0] + root = anki_profile_path_root() + name = ANKI.PROFILE_NAME + if name and os.path.isdir(os.path.join(root, name)): return name + if os.path.isfile(FILES.USER.LAST_PROFILE_LOCATION): + name = file(FILES.USER.LAST_PROFILE_LOCATION, 'r').read().strip() + if name and os.path.isdir(os.path.join(root, name)): return name + dirs = [x for x in os.listdir(root) if os.path.isdir(os.path.join(root, x)) and x is not 'addons'] + if not dirs: return "" + return dirs[0] + def ankDBSetLocal(): - global dbLocal - dbLocal = True + global dbLocal + dbLocal = True def ankDBIsLocal(): - global dbLocal - return dbLocal + global dbLocal + return dbLocal + def ankDB(reset=False): - global ankNotesDBInstance, dbLocal - if not ankNotesDBInstance or reset: - if dbLocal: ankNotesDBInstance = ank_DB( os.path.abspath(os.path.join(anki_profile_path_root(), last_anki_profile_name(), 'collection.anki2'))) - else: ankNotesDBInstance = ank_DB() - return ankNotesDBInstance + global ankNotesDBInstance, dbLocal + if not ankNotesDBInstance or reset: + if dbLocal: + ankNotesDBInstance = ank_DB( + os.path.abspath(os.path.join(anki_profile_path_root(), last_anki_profile_name(), 'collection.anki2'))) + else: + ankNotesDBInstance = ank_DB() + return ankNotesDBInstance def escape_text_sql(title): - return title.replace("'", "''") + return title.replace("'", "''") + def delete_anki_notes_and_cards_by_guid(evernote_guids): - ankDB().executemany("DELETE FROM cards WHERE nid in (SELECT id FROM notes WHERE flds LIKE '%' || ? || '%'); " - + "DELETE FROM notes WHERE flds LIKE '%' || ? || '%'", - [[FIELDS.EVERNOTE_GUID_PREFIX + x, FIELDS.EVERNOTE_GUID_PREFIX + x] for x in evernote_guids]) + ankDB().executemany("DELETE FROM cards WHERE nid in (SELECT id FROM notes WHERE flds LIKE '%' || ? || '%'); " + + "DELETE FROM notes WHERE flds LIKE '%' || ? || '%'", + [[FIELDS.EVERNOTE_GUID_PREFIX + x, FIELDS.EVERNOTE_GUID_PREFIX + x] for x in evernote_guids]) + def get_evernote_title_from_guid(guid): - return ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, guid)) + return ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, guid)) def get_anki_deck_id_from_note_id(nid): - return long(ankDB().scalar("SELECT did FROM cards WHERE nid = ?", nid)) + return long(ankDB().scalar("SELECT did FROM cards WHERE nid = ?", nid)) def get_evernote_guid_from_anki_fields(fields): - if not FIELDS.EVERNOTE_GUID in fields: return None - return fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') + if not FIELDS.EVERNOTE_GUID in fields: return None + return fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') def get_all_local_db_guids(): - return [x[0] for x in ankDB().all("SELECT guid FROM %s WHERE 1 ORDER BY title ASC" % TABLES.EVERNOTE.NOTES)] + return [x[0] for x in ankDB().all("SELECT guid FROM %s WHERE 1 ORDER BY title ASC" % TABLES.EVERNOTE.NOTES)] class ank_DB(object): - def __init__(self, path=None, text=None, timeout=0): - encpath = path - if isinstance(encpath, unicode): - encpath = path.encode("utf-8") - if path: - self._db = sqlite.connect(encpath, timeout=timeout) - self._db.row_factory = sqlite.Row - if text: - self._db.text_factory = text - self._path = path - else: - self._db = mw.col.db._db - self._path = mw.col.db._path - self._db.row_factory = sqlite.Row - self.echo = os.environ.get("DBECHO") - self.mod = False - - def setrowfactory(self): - self._db.row_factory = sqlite.Row - - def execute(self, sql, *a, **ka): - s = sql.strip().lower() - # mark modified? - for stmt in "insert", "update", "delete": - if s.startswith(stmt): - self.mod = True - t = time.time() - if ka: - # execute("...where id = :id", id=5) - res = self._db.execute(sql, ka) - elif a: - # execute("...where id = ?", 5) - res = self._db.execute(sql, a) - else: - res = self._db.execute(sql) - if self.echo: - # print a, ka - print sql, "%0.3fms" % ((time.time() - t) * 1000) - if self.echo == "2": - print a, ka - return res - - def executemany(self, sql, l): - self.mod = True - t = time.time() - self._db.executemany(sql, l) - if self.echo: - print sql, "%0.3fms" % ((time.time() - t) * 1000) - if self.echo == "2": - print l - - def commit(self): - t = time.time() - self._db.commit() - if self.echo: - print "commit %0.3fms" % ((time.time() - t) * 1000) - - def executescript(self, sql): - self.mod = True - if self.echo: - print sql - self._db.executescript(sql) - - def rollback(self): - self._db.rollback() - - def scalar(self, sql, *a, **kw): - res = self.execute(sql, *a, **kw).fetchone() - if res: - return res[0] - return None - - def all(self, sql, *a, **kw): - return self.execute(sql, *a, **kw).fetchall() - - def first(self, sql, *a, **kw): - c = self.execute(sql, *a, **kw) - res = c.fetchone() - c.close() - return res - - def list(self, sql, *a, **kw): - return [x[0] for x in self.execute(sql, *a, **kw)] - - def close(self): - self._db.close() - - def set_progress_handler(self, *args): - self._db.set_progress_handler(*args) - - def __enter__(self): - self._db.execute("begin") - return self - - def __exit__(self, exc_type, *args): - self._db.close() - - def totalChanges(self): - return self._db.total_changes - - def interrupt(self): - self._db.interrupt() - - def InitTags(self, force=False): - if_exists = " IF NOT EXISTS" if not force else "" - self.execute( - """CREATE TABLE %s `%s` ( `guid` TEXT NOT NULL UNIQUE, `name` TEXT NOT NULL, `parentGuid` TEXT, `updateSequenceNum` INTEGER NOT NULL, PRIMARY KEY(guid) );""" % ( - if_exists, TABLES.EVERNOTE.TAGS)) - - def InitNotebooks(self, force=False): - if_exists = " IF NOT EXISTS" if not force else "" - self.execute( - """CREATE TABLE %s `%s` ( `guid` TEXT NOT NULL UNIQUE, `name` TEXT NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `serviceUpdated` INTEGER NOT NULL, `stack` TEXT, PRIMARY KEY(guid) );""" % ( - if_exists, TABLES.EVERNOTE.NOTEBOOKS)) - - def InitSeeAlso(self, forceRebuild=False): - if_exists = "IF NOT EXISTS" - if forceRebuild: - self.execute("DROP TABLE %s " % TABLES.SEE_ALSO) - self.commit() - if_exists = "" - self.execute( - """CREATE TABLE %s `%s` ( `id` INTEGER, `source_evernote_guid` TEXT NOT NULL, `number` INTEGER NOT NULL DEFAULT 100, `uid` INTEGER NOT NULL DEFAULT -1, `shard` TEXT NOT NULL DEFAULT -1, `target_evernote_guid` TEXT NOT NULL, `html` TEXT NOT NULL, `title` TEXT NOT NULL, `from_toc` INTEGER DEFAULT 0, `is_toc` INTEGER DEFAULT 0, `is_outline` INTEGER DEFAULT 0, PRIMARY KEY(id) );""" % (if_exists, TABLES.SEE_ALSO)) - - def Init(self): - self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL UNIQUE, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL, PRIMARY KEY(guid) );""" % TABLES.EVERNOTE.NOTES) - self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL)""" % TABLES.EVERNOTE.NOTES_HISTORY) - self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `root_title` TEXT NOT NULL UNIQUE, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL, `notebookGuid` TEXT NOT NULL, PRIMARY KEY(root_title) );""" % TABLES.AUTO_TOC) - self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT, `title` TEXT NOT NULL, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL DEFAULT ',,', `notebookGuid` TEXT, `validation_status` INTEGER NOT NULL DEFAULT 0, `validation_result` TEXT);""" % TABLES.NOTE_VALIDATION_QUEUE) - self.InitSeeAlso() - self.InitTags() - self.InitNotebooks() + def __init__(self, path=None, text=None, timeout=0): + encpath = path + if isinstance(encpath, unicode): + encpath = path.encode("utf-8") + if path: + self._db = sqlite.connect(encpath, timeout=timeout) + self._db.row_factory = sqlite.Row + if text: + self._db.text_factory = text + self._path = path + else: + self._db = mw.col.db._db + self._path = mw.col.db._path + self._db.row_factory = sqlite.Row + self.echo = os.environ.get("DBECHO") + self.mod = False + + def setrowfactory(self): + self._db.row_factory = sqlite.Row + + def execute(self, sql, *a, **ka): + s = sql.strip().lower() + # mark modified? + for stmt in "insert", "update", "delete": + if s.startswith(stmt): + self.mod = True + t = time.time() + if ka: + # execute("...where id = :id", id=5) + res = self._db.execute(sql, ka) + elif a: + # execute("...where id = ?", 5) + res = self._db.execute(sql, a) + else: + res = self._db.execute(sql) + if self.echo: + # print a, ka + print + sql, "%0.3fms" % ((time.time() - t) * 1000) + if self.echo == "2": + print + a, ka + return res + + def executemany(self, sql, l): + self.mod = True + t = time.time() + self._db.executemany(sql, l) + if self.echo: + print + sql, "%0.3fms" % ((time.time() - t) * 1000) + if self.echo == "2": + print + l + + def commit(self): + t = time.time() + self._db.commit() + if self.echo: + print + "commit %0.3fms" % ((time.time() - t) * 1000) + + def executescript(self, sql): + self.mod = True + if self.echo: + print + sql + self._db.executescript(sql) + + def rollback(self): + self._db.rollback() + + def scalar(self, sql, *a, **kw): + print(sql) + print(type(sql)) + res = self.execute(sql, *a, **kw).fetchone() + if res: + return res[0] + return None + + def all(self, sql, *a, **kw): + return self.execute(sql, *a, **kw).fetchall() + + def first(self, sql, *a, **kw): + c = self.execute(sql, *a, **kw) + res = c.fetchone() + c.close() + return res + + def list(self, sql, *a, **kw): + return [x[0] for x in self.execute(sql, *a, **kw)] + + def close(self): + self._db.close() + + def set_progress_handler(self, *args): + self._db.set_progress_handler(*args) + + def __enter__(self): + self._db.execute("begin") + return self + + def __exit__(self, exc_type, *args): + self._db.close() + + def totalChanges(self): + return self._db.total_changes + + def interrupt(self): + self._db.interrupt() + + def InitTags(self, force=False): + if_exists = " IF NOT EXISTS" if not force else "" + self.execute( + """CREATE TABLE %s `%s` ( `guid` TEXT NOT NULL UNIQUE, `name` TEXT NOT NULL, `parentGuid` TEXT, `updateSequenceNum` INTEGER NOT NULL, PRIMARY KEY(guid) );""" % ( + if_exists, TABLES.EVERNOTE.TAGS)) + + def InitNotebooks(self, force=False): + if_exists = " IF NOT EXISTS" if not force else "" + self.execute( + """CREATE TABLE %s `%s` ( `guid` TEXT NOT NULL UNIQUE, `name` TEXT NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `serviceUpdated` INTEGER NOT NULL, `stack` TEXT, PRIMARY KEY(guid) );""" % ( + if_exists, TABLES.EVERNOTE.NOTEBOOKS)) + + def InitSeeAlso(self, forceRebuild=False): + if_exists = "IF NOT EXISTS" + if forceRebuild: + self.execute("DROP TABLE %s " % TABLES.SEE_ALSO) + self.commit() + if_exists = "" + self.execute( + """CREATE TABLE %s `%s` ( `id` INTEGER, `source_evernote_guid` TEXT NOT NULL, `number` INTEGER NOT NULL DEFAULT 100, `uid` INTEGER NOT NULL DEFAULT -1, `shard` TEXT NOT NULL DEFAULT -1, `target_evernote_guid` TEXT NOT NULL, `html` TEXT NOT NULL, `title` TEXT NOT NULL, `from_toc` INTEGER DEFAULT 0, `is_toc` INTEGER DEFAULT 0, `is_outline` INTEGER DEFAULT 0, PRIMARY KEY(id) );""" % ( + if_exists, TABLES.SEE_ALSO)) + + def Init(self): + self.execute( + """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL UNIQUE, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL, PRIMARY KEY(guid) );""" % TABLES.EVERNOTE.NOTES) + self.execute( + """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL)""" % TABLES.EVERNOTE.NOTES_HISTORY) + self.execute( + """CREATE TABLE IF NOT EXISTS `%s` ( `root_title` TEXT NOT NULL UNIQUE, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL, `notebookGuid` TEXT NOT NULL, PRIMARY KEY(root_title) );""" % TABLES.AUTO_TOC) + self.execute( + """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT, `title` TEXT NOT NULL, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL DEFAULT ',,', `notebookGuid` TEXT, `validation_status` INTEGER NOT NULL DEFAULT 0, `validation_result` TEXT);""" % TABLES.NOTE_VALIDATION_QUEUE) + self.InitSeeAlso() + self.InitTags() + self.InitNotebooks() diff --git a/anknotes/detect_see_also_changes.py b/anknotes/detect_see_also_changes.py index 9893a2a..8e20969 100644 --- a/anknotes/detect_see_also_changes.py +++ b/anknotes/detect_see_also_changes.py @@ -3,9 +3,9 @@ import sys try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite from anknotes.shared import * from anknotes import stopwatch @@ -21,252 +21,275 @@ from anknotes.ankEvernote import Evernote from anknotes.Anki import Anki + class notes: - class version(object): - class pstrings: - __updated__ = None - __processed__ = None - __original__ = None - __regex_updated__ = None - """: type : notes.version.see_also_match """ - __regex_processed__ = None - """: type : notes.version.see_also_match """ - __regex_original__ = None - """: type : notes.version.see_also_match """ - - @property - def regex_original(self): - if self.original is None: return None - if self.__regex_original__ is None: - self.__regex_original__ = notes.version.see_also_match(self.original) - return self.__regex_original__ - - @property - def regex_processed(self): - if self.processed is None: return None - if self.__regex_processed__ is None: - self.__regex_processed__ = notes.version.see_also_match(self.processed) - return self.__regex_processed__ - - @property - def regex_updated(self): - if self.updated is None: return None - if self.__regex_updated__ is None: - self.__regex_updated__ = notes.version.see_also_match(self.updated) - return self.__regex_updated__ - - @property - def processed(self): - if self.__processed__ is None: - self.__processed__ = str_process(self.original) - return self.__processed__ - - @property - def updated(self): - if self.__updated__ is None: return str_process(self.__original__) - return self.__updated__ - - @updated.setter - def updated(self, value): - self.__regex_updated__ = None - self.__updated__ = value - - @property - def final(self): - return str_process_full(self.updated) - - @property - def original(self): - return self.__original__ - - def useProcessed(self): - self.updated = self.processed - - def __init__(self, original=None): - self.__original__ = original - - class see_also_match(object): - __subject__ = None - __content__ = None - __matchobject__ = None - """:type : anknotes._re.__Match """ - __match_attempted__ = 0 - - @property - def subject(self): - if not self.__subject__: return self.content - return self.__subject__ - - @subject.setter - def subject(self, value): - self.__subject__ = value - self.__match_attempted__ = 0 - self.__matchobject__ = None - - @property - def content(self): - return self.__content__ - - def groups(self, group=0): - """ - :param group: - :type group : int | str | unicode - :return: - """ - if not self.successful_match: - return None - return self.__matchobject__.group(group) - - @property - def successful_match(self): - if self.__matchobject__: return True - if self.__match_attempted__ is 0 and self.subject is not None: - self.__matchobject__ = notes.rgx.search(self.subject) - """:type : anknotes._re.__Match """ - self.__match_attempted__ += 1 - return self.__matchobject__ is not None - - @property - def main(self): - return self.groups(0) - - @property - def see_also(self): - return self.groups('SeeAlso') - - @property - def see_also_content(self): - return self.groups('SeeAlsoContent') - - def __init__(self, content=None): - """ - - :type content: str | unicode - """ - self.__content__ = content - self.__match_attempted__ = 0 - self.__matchobject__ = None - """:type : anknotes._re.__Match """ - content = pstrings() - see_also = pstrings() - old = version() - new = version() - rgx = regex_see_also() - match_type = 'NA' + class version(object): + class pstrings: + __updated__ = None + __processed__ = None + __original__ = None + __regex_updated__ = None + """: type : notes.version.see_also_match """ + __regex_processed__ = None + """: type : notes.version.see_also_match """ + __regex_original__ = None + """: type : notes.version.see_also_match """ + + @property + def regex_original(self): + if self.original is None: return None + if self.__regex_original__ is None: + self.__regex_original__ = notes.version.see_also_match(self.original) + return self.__regex_original__ + + @property + def regex_processed(self): + if self.processed is None: return None + if self.__regex_processed__ is None: + self.__regex_processed__ = notes.version.see_also_match(self.processed) + return self.__regex_processed__ + + @property + def regex_updated(self): + if self.updated is None: return None + if self.__regex_updated__ is None: + self.__regex_updated__ = notes.version.see_also_match(self.updated) + return self.__regex_updated__ + + @property + def processed(self): + if self.__processed__ is None: + self.__processed__ = str_process(self.original) + return self.__processed__ + + @property + def updated(self): + if self.__updated__ is None: return str_process(self.__original__) + return self.__updated__ + + @updated.setter + def updated(self, value): + self.__regex_updated__ = None + self.__updated__ = value + + @property + def final(self): + return str_process_full(self.updated) + + @property + def original(self): + return self.__original__ + + def useProcessed(self): + self.updated = self.processed + + def __init__(self, original=None): + self.__original__ = original + + class see_also_match(object): + __subject__ = None + __content__ = None + __matchobject__ = None + """:type : anknotes._re.__Match """ + __match_attempted__ = 0 + + @property + def subject(self): + if not self.__subject__: return self.content + return self.__subject__ + + @subject.setter + def subject(self, value): + self.__subject__ = value + self.__match_attempted__ = 0 + self.__matchobject__ = None + + @property + def content(self): + return self.__content__ + + def groups(self, group=0): + """ + :param group: + :type group : int | str | unicode + :return: + """ + if not self.successful_match: + return None + return self.__matchobject__.group(group) + + @property + def successful_match(self): + if self.__matchobject__: return True + if self.__match_attempted__ is 0 and self.subject is not None: + self.__matchobject__ = notes.rgx.search(self.subject) + """:type : anknotes._re.__Match """ + self.__match_attempted__ += 1 + return self.__matchobject__ is not None + + @property + def main(self): + return self.groups(0) + + @property + def see_also(self): + return self.groups('SeeAlso') + + @property + def see_also_content(self): + return self.groups('SeeAlsoContent') + + def __init__(self, content=None): + """ + + :type content: str | unicode + """ + self.__content__ = content + self.__match_attempted__ = 0 + self.__matchobject__ = None + """:type : anknotes._re.__Match """ + + content = pstrings() + see_also = pstrings() + + old = version() + new = version() + rgx = regex_see_also() + match_type = 'NA' def str_process(strr): - if not strr: return strr - strr = strr.replace(u"evernote:///", u"evernote://") - strr = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', - r'evernote://view/\2/\1/\3/\3/', strr) - strr = strr.replace(u"evernote://", u"evernote:///").replace(u'<BR>', u'<br />') - strr = re.sub(r'<br ?/?>', u'<br/>', strr, 0, re.IGNORECASE) - strr = re.sub(r'(?s)<<(?P<PrefixKeep>(?:</div>)?)<div class="occluded">(?P<OccludedText>.+?)</div>>>', r'<<\g<PrefixKeep>>>', strr) - strr = strr.replace('<span class="occluded">', '<span style="color: rgb(255, 255, 255);">') - return strr + if not strr: return strr + strr = strr.replace(u"evernote:///", u"evernote://") + strr = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', + r'evernote://view/\2/\1/\3/\3/', strr) + strr = strr.replace(u"evernote://", u"evernote:///").replace(u'<BR>', u'<br />') + strr = re.sub(r'<br ?/?>', u'<br/>', strr, 0, re.IGNORECASE) + strr = re.sub(r'(?s)<<(?P<PrefixKeep>(?:</div>)?)<div class="occluded">(?P<OccludedText>.+?)</div>>>', + r'<<\g<PrefixKeep>>>', strr) + strr = strr.replace('<span class="occluded">', '<span style="color: rgb(255, 255, 255);">') + return strr + def str_process_full(strr): - return clean_evernote_css(strr) + return clean_evernote_css(strr) + def main(evernote=None, anki=None): - # @clockit - def print_results(log_folder='Diff\\SeeAlso',full=False, final=False): - if final: - oldResults=n.old.content.final - newResults=n.new.content.final - elif full: - oldResults=n.old.content.updated - newResults=n.new.content.updated - else: - oldResults=n.old.see_also.updated - newResults=n.new.see_also.updated - diff = generate_diff(oldResults, newResults) - log.plain(diff, log_folder+'\\Diff\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) - log.plain(diffify(oldResults,split=False), log_folder+'\\Original\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) - log.plain(diffify(newResults,split=False), log_folder+'\\New\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) - if final: - log.plain(oldResults, log_folder+'\\Final\\Old\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) - log.plain(newResults, log_folder+'\\Final\\New\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) - log.plain(diff + '\n', log_folder+'\\__All') - - # @clockit - def process_note(): - n.old.content = notes.version.pstrings(enNote.Content) - if not n.old.content.regex_original.successful_match: - if n.new.see_also.original == "": - n.new.content = notes.version.pstrings(n.old.content.original) - return False - n.new.content = notes.version.pstrings(n.old.content.original.replace('</en-note>', '<div><span><br/></span></div>' + n.new.see_also.original + '\n</en-note>')) - n.new.see_also.updated = str_process(n.new.content.original) - n.old.see_also.updated = str_process(n.old.content.original) - log.plain(enNote.Guid + '<BR>' + ', '.join(enNote.TagNames) + '<HR>' + enNote.Content + '<HR>' + n.new.see_also.updated, 'SeeAlsoMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) - n.match_type = 'V1' - else: - n.old.see_also = notes.version.pstrings(n.old.content.regex_original.main) - n.match_type = 'V2' - if n.old.see_also.regex_processed.successful_match: - assert True or str_process(n.old.content.regex_original.main) is n.old.content.regex_processed.main - n.old.content.updated = n.old.content.original.replace(n.old.content.regex_original.main, str_process(n.old.content.regex_original.main)) - n.old.see_also.useProcessed() - n.match_type += 'V3' - n.new.see_also.regex_original.subject = n.new.see_also.original + '</en-note>' - if not n.new.see_also.regex_original.successful_match: - log.plain(enNote.Guid + '\n' + ', '.join(enNote.TagNames) + '\n' + n.new.see_also.original.content, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) - see_also_replace_old = n.old.content.original.match.processed.see_also.processed.content - n.old.see_also.updated = n.old.content.regex_updated.see_also - n.new.see_also.updated = n.new.see_also.processed - n.match_type + 'V4' - else: - assert (n.old.content.regex_processed.see_also_content == notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content) - n.old.see_also.updated = notes.version.see_also_match(str_process(n.old.content.regex_original.main)).see_also_content - n.new.see_also.updated = str_process(n.new.see_also.regex_original.see_also_content) - n.match_type += 'V5' - n.new.content.updated = n.old.content.updated.replace(n.old.see_also.updated, n.new.see_also.updated) - log = Logger(default_filename='SeeAlsoDiff\\__ALL', rm_path=True) - # SELECT DISTINCT s.target_evernote_guid FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid ORDER BY n.title ASC - # SELECT DISTINCT s.target_evernote_guid, n.* FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid ORDER BY n.title ASC; - # SELECT DISTINCT s.target_evernote_guid, n.* FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid AND n.tagNames NOT LIKE '%,#TOC,%' AND n.tagNames NOT LIKE '%,#Outline,%' ORDER BY n.title ASC; - sql = "SELECT DISTINCT s.target_evernote_guid, n.* FROM %s as s, %s as n WHERE s.target_evernote_guid = n.guid AND n.tagNames NOT LIKE '%%,%s,%%' AND n.tagNames NOT LIKE '%%,%s,%%' ORDER BY n.title ASC;" - results = ankDB().all(sql % (TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES, TAGS.TOC, TAGS.OUTLINE)) - # count_queued = 0 - tmr = stopwatch.Timer(len(results), 25, 'Updating See Also Notes', label='SeeAlso-Step7', display_initial_info=False) - log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), do_print=True) - log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), tmr.label) - notes_updated=[] - # number_updated = 0 - for result in results: - enNote = EvernoteNotePrototype(db_note=result) - n = notes() - if tmr.step(): - log.go("Note %5s: %s: %s" % ('#' + str(tmr.count), tmr.progress, enNote.FullTitle if enNote.Status.IsSuccess else '(%s)' % enNote.Guid), do_print=True, print_timestamp=False) - flds = ankDB().scalar("SELECT flds FROM notes WHERE flds LIKE '%%%s%s%%'" % (FIELDS.EVERNOTE_GUID_PREFIX, enNote.Guid)).split("\x1f") - n.new.see_also = notes.version.pstrings(flds[FIELDS.ORD.SEE_ALSO]) - result = process_note() - if result is False: - log.go('No match for %s' % enNote.FullTitle, tmr.label + '-NoUpdate') - print_results('NoMatch\\SeeAlso') - print_results('NoMatch\\Contents', full=True) - continue - if n.match_type != 'V1' and str_process(n.old.see_also.updated) == n.new.see_also.updated: - log.go('Match but contents are the same for %s' % enNote.FullTitle, tmr.label + '-NoUpdate') - print_results('Same\\SeeAlso') - print_results('Same\\Contents', full=True) - continue - print_results() - print_results('Diff\\Contents', final=True) - enNote.Content = n.new.content.final - if not evernote: evernote = Evernote() - whole_note = tmr.autoStep(evernote.makeNote(enNote=enNote), enNote.FullTitle, True) - if tmr.reportStatus(status) == False: raise ValueError - if tmr.status.IsDelayableError: break - if tmr.status.IsSuccess: notes_updated.append(EvernoteNotePrototype(whole_note=whole_note)) - if tmr.is_success and not anki: anki = Anki() - tmr.Report(0, anki.update_evernote_notes(notes_updated) if tmr.is_success else 0) - # log.go("Total %d of %d note(s) successfully uploaded to Evernote" % (tmr.count_success, tmr.max), tmr.label, do_print=True) - # if number_updated > 0: log.go(" > %4d updated in Anki" % number_updated, tmr.label, do_print=True) - # if tmr.count_queued > 0: log.go(" > %4d queued for validation" % tmr.count_queued, tmr.label, do_print=True) - # if tmr.count_error > 0: log.go(" > %4d error(s) occurred" % tmr.count_error, tmr.label, do_print=True) - - -## HOCM/MVP \ No newline at end of file + # @clockit + def print_results(log_folder='Diff\\SeeAlso', full=False, final=False): + if final: + oldResults = n.old.content.final + newResults = n.new.content.final + elif full: + oldResults = n.old.content.updated + newResults = n.new.content.updated + else: + oldResults = n.old.see_also.updated + newResults = n.new.see_also.updated + diff = generate_diff(oldResults, newResults) + log.plain(diff, log_folder + '\\Diff\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', clear=True) + log.plain(diffify(oldResults, split=False), log_folder + '\\Original\\%s\\' % n.match_type + enNote.FullTitle, + extension='htm', clear=True) + log.plain(diffify(newResults, split=False), log_folder + '\\New\\%s\\' % n.match_type + enNote.FullTitle, + extension='htm', clear=True) + if final: + log.plain(oldResults, log_folder + '\\Final\\Old\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', + clear=True) + log.plain(newResults, log_folder + '\\Final\\New\\%s\\' % n.match_type + enNote.FullTitle, extension='htm', + clear=True) + log.plain(diff + '\n', log_folder + '\\__All') + + # @clockit + def process_note(): + n.old.content = notes.version.pstrings(enNote.Content) + if not n.old.content.regex_original.successful_match: + if n.new.see_also.original == "": + n.new.content = notes.version.pstrings(n.old.content.original) + return False + n.new.content = notes.version.pstrings(n.old.content.original.replace('</en-note>', + '<div><span><br/></span></div>' + n.new.see_also.original + '\n</en-note>')) + n.new.see_also.updated = str_process(n.new.content.original) + n.old.see_also.updated = str_process(n.old.content.original) + log.plain(enNote.Guid + '<BR>' + ', '.join( + enNote.TagNames) + '<HR>' + enNote.Content + '<HR>' + n.new.see_also.updated, + 'SeeAlsoMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) + n.match_type = 'V1' + else: + n.old.see_also = notes.version.pstrings(n.old.content.regex_original.main) + n.match_type = 'V2' + if n.old.see_also.regex_processed.successful_match: + assert True or str_process(n.old.content.regex_original.main) is n.old.content.regex_processed.main + n.old.content.updated = n.old.content.original.replace(n.old.content.regex_original.main, + str_process(n.old.content.regex_original.main)) + n.old.see_also.useProcessed() + n.match_type += 'V3' + n.new.see_also.regex_original.subject = n.new.see_also.original + '</en-note>' + if not n.new.see_also.regex_original.successful_match: + log.plain(enNote.Guid + '\n' + ', '.join(enNote.TagNames) + '\n' + n.new.see_also.original.content, + 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) + see_also_replace_old = n.old.content.original.match.processed.see_also.processed.content + n.old.see_also.updated = n.old.content.regex_updated.see_also + n.new.see_also.updated = n.new.see_also.processed + n.match_type + 'V4' + else: + assert (n.old.content.regex_processed.see_also_content == notes.version.see_also_match( + str_process(n.old.content.regex_original.main)).see_also_content) + n.old.see_also.updated = notes.version.see_also_match( + str_process(n.old.content.regex_original.main)).see_also_content + n.new.see_also.updated = str_process(n.new.see_also.regex_original.see_also_content) + n.match_type += 'V5' + n.new.content.updated = n.old.content.updated.replace(n.old.see_also.updated, n.new.see_also.updated) + + log = Logger(default_filename='SeeAlsoDiff\\__ALL', rm_path=True) + # SELECT DISTINCT s.target_evernote_guid FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid ORDER BY n.title ASC + # SELECT DISTINCT s.target_evernote_guid, n.* FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid ORDER BY n.title ASC; + # SELECT DISTINCT s.target_evernote_guid, n.* FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid AND n.tagNames NOT LIKE '%,#TOC,%' AND n.tagNames NOT LIKE '%,#Outline,%' ORDER BY n.title ASC; + sql = "SELECT DISTINCT s.target_evernote_guid, n.* FROM %s as s, %s as n WHERE s.target_evernote_guid = n.guid AND n.tagNames NOT LIKE '%%,%s,%%' AND n.tagNames NOT LIKE '%%,%s,%%' ORDER BY n.title ASC;" + results = ankDB().all(sql % (TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES, TAGS.TOC, TAGS.OUTLINE)) + # count_queued = 0 + tmr = stopwatch.Timer(len(results), 25, 'Updating See Also Notes', label='SeeAlso-Step7', + display_initial_info=False) + log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), do_print=True) + log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), tmr.label) + notes_updated = [] + # number_updated = 0 + for result in results: + enNote = EvernoteNotePrototype(db_note=result) + n = notes() + if tmr.step(): + log.go("Note %5s: %s: %s" % ( + '#' + str(tmr.count), tmr.progress, enNote.FullTitle if enNote.Status.IsSuccess else '(%s)' % enNote.Guid), + do_print=True, print_timestamp=False) + flds = ankDB().scalar( + "SELECT flds FROM notes WHERE flds LIKE '%%%s%s%%'" % (FIELDS.EVERNOTE_GUID_PREFIX, enNote.Guid)).split( + "\x1f") + n.new.see_also = notes.version.pstrings(flds[FIELDS.ORD.SEE_ALSO]) + result = process_note() + if result is False: + log.go('No match for %s' % enNote.FullTitle, tmr.label + '-NoUpdate') + print_results('NoMatch\\SeeAlso') + print_results('NoMatch\\Contents', full=True) + continue + if n.match_type != 'V1' and str_process(n.old.see_also.updated) == n.new.see_also.updated: + log.go('Match but contents are the same for %s' % enNote.FullTitle, tmr.label + '-NoUpdate') + print_results('Same\\SeeAlso') + print_results('Same\\Contents', full=True) + continue + print_results() + print_results('Diff\\Contents', final=True) + enNote.Content = n.new.content.final + if not evernote: evernote = Evernote() + whole_note = tmr.autoStep(evernote.makeNote(enNote=enNote), enNote.FullTitle, True) + if tmr.reportStatus(status) == False: raise ValueError + if tmr.status.IsDelayableError: break + if tmr.status.IsSuccess: notes_updated.append(EvernoteNotePrototype(whole_note=whole_note)) + if tmr.is_success and not anki: anki = Anki() + tmr.Report(0, anki.update_evernote_notes(notes_updated) if tmr.is_success else 0) + # log.go("Total %d of %d note(s) successfully uploaded to Evernote" % (tmr.count_success, tmr.max), tmr.label, do_print=True) + # if number_updated > 0: log.go(" > %4d updated in Anki" % number_updated, tmr.label, do_print=True) + # if tmr.count_queued > 0: log.go(" > %4d queued for validation" % tmr.count_queued, tmr.label, do_print=True) + # if tmr.count_error > 0: log.go(" > %4d error(s) occurred" % tmr.count_error, tmr.label, do_print=True) + + + ## HOCM/MVP \ No newline at end of file diff --git a/anknotes/enums.py b/anknotes/enums.py index 6fd660e..3a9f156 100644 --- a/anknotes/enums.py +++ b/anknotes/enums.py @@ -1,79 +1,92 @@ from anknotes.enum import Enum, EnumMeta, IntEnum from anknotes import enum + + class AutoNumber(Enum): - def __new__(cls, *args): - """ - - :param cls: - :return: - :rtype : AutoNumber - """ - value = len(cls.__members__) + 1 - if args and args[0]: value=args[0] - while value in cls._value2member_map_: value += 1 - obj = object.__new__(cls) - obj._id_ = value - obj._value_ = value - # if obj.name in obj._member_names_: - # raise KeyError - return obj + def __new__(cls, *args): + """ + + :param cls: + :return: + :rtype : AutoNumber + """ + value = len(cls.__members__) + 1 + if args and args[0]: value = args[0] + while value in cls._value2member_map_: value += 1 + obj = object.__new__(cls) + obj._id_ = value + obj._value_ = value + # if obj.name in obj._member_names_: + # raise KeyError + return obj + + class OrderedEnum(Enum): - def __ge__(self, other): - if self.__class__ is other.__class__: - return self._value_ >= other._value_ - return NotImplemented - def __gt__(self, other): - if self.__class__ is other.__class__: - return self._value_ > other._value_ - return NotImplemented - def __le__(self, other): - if self.__class__ is other.__class__: - return self._value_ <= other._value_ - return NotImplemented - def __lt__(self, other): - if self.__class__ is other.__class__: - return self._value_ < other._value_ - return NotImplemented + def __ge__(self, other): + if self.__class__ is other.__class__: + return self._value_ >= other._value_ + return NotImplemented + + def __gt__(self, other): + if self.__class__ is other.__class__: + return self._value_ > other._value_ + return NotImplemented + + def __le__(self, other): + if self.__class__ is other.__class__: + return self._value_ <= other._value_ + return NotImplemented + + def __lt__(self, other): + if self.__class__ is other.__class__: + return self._value_ < other._value_ + return NotImplemented + + class auto_enum(EnumMeta): - def __new__(metacls, cls, bases, classdict): - original_dict = classdict - classdict = enum._EnumDict() - for k, v in original_dict.items(): - classdict[k] = v - temp = type(classdict)() - names = set(classdict._member_names) - i = 0 - - for k in classdict._member_names: - v = classdict[k] - if v == () : - v = i - else: - i = max(v, i) - i += 1 - temp[k] = v - for k, v in classdict.items(): - if k not in names: - temp[k] = v - return super(auto_enum, metacls).__new__( - metacls, cls, bases, temp) - - def __ge__(self, other): - if self.__class__ is other.__class__: - return self._value_ >= other._value_ - return NotImplemented - def __gt__(self, other): - if self.__class__ is other.__class__: - return self._value_ > other._value_ - return NotImplemented - def __le__(self, other): - if self.__class__ is other.__class__: - return self._value_ <= other._value_ - return NotImplemented - def __lt__(self, other): - if self.__class__ is other.__class__: - return self._value_ < other._value_ - return NotImplemented + def __new__(metacls, cls, bases, classdict): + original_dict = classdict + classdict = enum._EnumDict() + for k, v in original_dict.items(): + classdict[k] = v + temp = type(classdict)() + names = set(classdict._member_names) + i = 0 + + for k in classdict._member_names: + v = classdict[k] + if v == (): + v = i + else: + i = max(v, i) + i += 1 + temp[k] = v + for k, v in classdict.items(): + if k not in names: + temp[k] = v + return super(auto_enum, metacls).__new__( + metacls, cls, bases, temp) + + def __ge__(self, other): + if self.__class__ is other.__class__: + return self._value_ >= other._value_ + return NotImplemented + + def __gt__(self, other): + if self.__class__ is other.__class__: + return self._value_ > other._value_ + return NotImplemented + + def __le__(self, other): + if self.__class__ is other.__class__: + return self._value_ <= other._value_ + return NotImplemented + + def __lt__(self, other): + if self.__class__ is other.__class__: + return self._value_ < other._value_ + return NotImplemented + AutoNumberedEnum = auto_enum('AutoNumberedEnum', (OrderedEnum,), {}) @@ -83,7 +96,7 @@ def __lt__(self, other): # # # class APIStatus(AutoIntEnum): -# Val1=() +# Val1=() # """:type : AutoIntEnum""" # Val2=() # """:type : AutoIntEnum""" diff --git a/anknotes/error.py b/anknotes/error.py index f9856b8..d87f705 100644 --- a/anknotes/error.py +++ b/anknotes/error.py @@ -7,82 +7,88 @@ def HandleSocketError(e, strErrorBase): - global latestSocketError - errorcode = e[0] - friendly_error_msgs = { - errno.ECONNREFUSED: "Connection was refused", - errno.WSAECONNRESET: "Connection was reset or forcibly closed by the remote host", - errno.ETIMEDOUT: "Connection timed out" - } - if errorcode not in errno.errorcode: - log_error("Unknown socket error (%s) occurred: %s" % (str(errorcode), str(e))) - return False - error_constant = errno.errorcode[errorcode] - if errorcode in friendly_error_msgs: - strError = friendly_error_msgs[errorcode] - else: - strError = "Unhandled socket error (%s) occurred" % error_constant - latestSocketError = {'code': errorcode, 'friendly_error_msg': strError, 'constant': error_constant} - strError = "Error: %s while %s\r\n" % (strError, strErrorBase) - log_error(" SocketError.%s: " % error_constant + strError) - log_error(str(e)) - log(" SocketError.%s: " % error_constant + strError, 'api') - if EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.AlertError: - showInfo(strError) - elif EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.ToolTipError: - show_tooltip(strError) - return True + global latestSocketError + errorcode = e[0] + friendly_error_msgs = { + errno.ECONNREFUSED: "Connection was refused", + errno.WSAECONNRESET: "Connection was reset or forcibly closed by the remote host", + errno.ETIMEDOUT: "Connection timed out" + } + if errorcode not in errno.errorcode: + log_error("Unknown socket error (%s) occurred: %s" % (str(errorcode), str(e))) + return False + error_constant = errno.errorcode[errorcode] + if errorcode in friendly_error_msgs: + strError = friendly_error_msgs[errorcode] + else: + strError = "Unhandled socket error (%s) occurred" % error_constant + latestSocketError = {'code': errorcode, 'friendly_error_msg': strError, 'constant': error_constant} + strError = "Error: %s while %s\r\n" % (strError, strErrorBase) + log_error(" SocketError.%s: " % error_constant + strError) + log_error(str(e)) + log(" SocketError.%s: " % error_constant + strError, 'api') + if EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.AlertError: + showInfo(strError) + elif EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.ToolTipError: + show_tooltip(strError) + return True latestEDAMRateLimit = 0 def HandleEDAMRateLimitError(e, strError): - global latestEDAMRateLimit - if not e.errorCode is EDAMErrorCode.RATE_LIMIT_REACHED: return False - latestEDAMRateLimit = e.rateLimitDuration - m, s = divmod(e.rateLimitDuration, 60) - strError = "Error: Rate limit has been reached while %s\r\n" % strError - strError += "Please retry your request in {} min".format("%d:%02d" % (m, s)) - log_strError = " EDAMErrorCode.RATE_LIMIT_REACHED: " + strError.replace('\r\n', '\n') - log_error(log_strError) - log(log_strError, 'api') - if EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.AlertError: - showInfo(strError) - elif EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.ToolTipError: - show_tooltip(strError) - return True - -lastUnicodeError=None - -def HandleUnicodeError(log_header, e, guid, title, action='', attempt=1, content=None, field=None, attempt_max=3, attempt_min=1): - do_log = False - object = "" - e_type = e.__class__.__name__ - is_unicode = e_type.find("Unicode") > -1 - if is_unicode: - content_type = e.object.__class__.__name__ - object = e.object[e.start-20:e.start+20] - elif not content: - content = "Not Provided" - content_type = "N/A" - else: - content_type = content.__class__.__name__ - log_header += ': ' + e_type + ': {field}' + content_type + (' <%s>' % action if action else '') - save_header = log_header.replace('{field}', '') + ': ' + title - log_header = log_header.format(field='%s: ' % field if field else '') - - new_error = lastUnicodeError != save_header - - if is_unicode: - return_val = 1 if attempt < attempt_max else -1 - if new_error: - log(save_header + '\n' + '-' * ANKNOTES.FORMATTING.LINE_LENGTH, 'unicode', replace_newline=False); - lastUnicodeError = save_header - log(ANKNOTES.FORMATTING.TIMESTAMP_PAD + '\t - ' + (('Field %s' % field if field else 'Unknown Field') + ': ').ljust(20) + str_safe(object), 'unicode', timestamp=False) - else: - return_val = 0 - if attempt is 1 and content: log_dump(content, log_header, 'NonUnicodeErrors') - if (new_error and attempt >= attempt_min) or not is_unicode: - log_error(log_header + "\n - Error: %s\n - GUID: %s\n - Title: %s%s" % (str(e), guid, str_safe(title), '' if not object else "\n - Object: %s" % str_safe(object))) - return return_val \ No newline at end of file + global latestEDAMRateLimit + if not e.errorCode is EDAMErrorCode.RATE_LIMIT_REACHED: return False + latestEDAMRateLimit = e.rateLimitDuration + m, s = divmod(e.rateLimitDuration, 60) + strError = "Error: Rate limit has been reached while %s\r\n" % strError + strError += "Please retry your request in {} min".format("%d:%02d" % (m, s)) + log_strError = " EDAMErrorCode.RATE_LIMIT_REACHED: " + strError.replace('\r\n', '\n') + log_error(log_strError) + log(log_strError, 'api') + if EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.AlertError: + showInfo(strError) + elif EVERNOTE.API.EDAM_RATE_LIMIT_ERROR_HANDLING is EVERNOTE.API.RateLimitErrorHandling.ToolTipError: + show_tooltip(strError) + return True + + +lastUnicodeError = None + + +def HandleUnicodeError(log_header, e, guid, title, action='', attempt=1, content=None, field=None, attempt_max=3, + attempt_min=1): + do_log = False + object = "" + e_type = e.__class__.__name__ + is_unicode = e_type.find("Unicode") > -1 + if is_unicode: + content_type = e.object.__class__.__name__ + object = e.object[e.start - 20:e.start + 20] + elif not content: + content = "Not Provided" + content_type = "N/A" + else: + content_type = content.__class__.__name__ + log_header += ': ' + e_type + ': {field}' + content_type + (' <%s>' % action if action else '') + save_header = log_header.replace('{field}', '') + ': ' + title + log_header = log_header.format(field='%s: ' % field if field else '') + + new_error = lastUnicodeError != save_header + + if is_unicode: + return_val = 1 if attempt < attempt_max else -1 + if new_error: + log(save_header + '\n' + '-' * ANKNOTES.FORMATTING.LINE_LENGTH, 'unicode', replace_newline=False); + lastUnicodeError = save_header + log(ANKNOTES.FORMATTING.TIMESTAMP_PAD + '\t - ' + ( + ('Field %s' % field if field else 'Unknown Field') + ': ').ljust(20) + str_safe(object), 'unicode', + timestamp=False) + else: + return_val = 0 + if attempt is 1 and content: log_dump(content, log_header, 'NonUnicodeErrors') + if (new_error and attempt >= attempt_min) or not is_unicode: + log_error(log_header + "\n - Error: %s\n - GUID: %s\n - Title: %s%s" % ( + str(e), guid, str_safe(title), '' if not object else "\n - Object: %s" % str_safe(object))) + return return_val \ No newline at end of file diff --git a/anknotes/find_deleted_notes.py b/anknotes/find_deleted_notes.py index 6e1ac9f..d2a6c18 100644 --- a/anknotes/find_deleted_notes.py +++ b/anknotes/find_deleted_notes.py @@ -1,138 +1,141 @@ # -*- coding: utf-8 -*- import os + try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite from anknotes.shared import * def do_find_deleted_notes(all_anki_notes=None): - """ - :param all_anki_notes: from Anki.get_evernote_guids_and_anki_fields_from_anki_note_ids() - :type : dict[str, dict[str, str]] - :return: - """ - - Error = sqlite.Error - - if not os.path.isfile(FILES.USER.TABLE_OF_CONTENTS_ENEX): - log_error('Unable to proceed with find_deleted_notes: TOC enex does not exist.', do_print=True) - return False - - enTableOfContents = file(FILES.USER.TABLE_OF_CONTENTS_ENEX, 'r').read() - # find = file(os.path.join(PATH, "powergrep-find.txt") , 'r').read().splitlines() - # replace = file(os.path.join(PATH, "powergrep-replace.txt") , 'r').read().replace('https://www.evernote.com/shard/s175/nl/19775535/' , '').splitlines() - - all_anknotes_notes = ankDB().all("SELECT guid, title, tagNames FROM %s " % TABLES.EVERNOTE.NOTES) - find_guids = {} - log_banner(' FIND DELETED EVERNOTE NOTES: UNIMPORTED EVERNOTE NOTES ', FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES) - log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKI NOTES ', FILES.LOGS.FDN.ANKI_ORPHANS) - log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKNOTES DB ENTRIES ', FILES.LOGS.FDN.ANKNOTES_ORPHANS) - log_banner(' FIND DELETED EVERNOTE NOTES: ANKNOTES TITLE MISMATCHES ', FILES.LOGS.FDN.ANKNOTES_TITLE_MISMATCHES) - log_banner(' FIND DELETED EVERNOTE NOTES: ANKI TITLE MISMATCHES ', FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES) - log_banner(' FIND DELETED EVERNOTE NOTES: POSSIBLE TOC NOTES MISSING TAG ', FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES + '_possibletoc') - anki_mismatch = 0 - is_toc_or_outline=[] - for line in all_anknotes_notes: - guid = line['guid'] - title = line['title'] - if not (',' + TAGS.TOC + ',' in line['tagNames']): - if title.upper() == title: - log_plain(guid + '::: %-50s: ' % line['tagNames'][1:-1] + title, FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES + '_possibletoc', do_print=True) - - title = clean_title(title) - title_safe = str_safe(title) - find_guids[guid] = title - if all_anki_notes: - if guid in all_anki_notes: - find_title = all_anki_notes[guid][FIELDS.TITLE] - find_title_safe = str_safe(find_title) - if find_title_safe == title_safe: - del all_anki_notes[guid] - else: - log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES) - anki_mismatch += 1 - mismatch = 0 - missing_evernote_notes = [] - for enLink in find_evernote_links(enTableOfContents): - guid = enLink.Guid - title = clean_title(enLink.FullTitle) - title_safe = str_safe(title) - - if guid in find_guids: - find_title = find_guids[guid] - find_title_safe = str_safe(find_title) - if find_title_safe == title_safe: - del find_guids[guid] - else: - log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKNOTES_TITLE_MISMATCHES) - mismatch += 1 - else: - log_plain(guid + '::: ' + title, FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES) - missing_evernote_notes.append(guid) - - anki_dels = [] - anknotes_dels = [] - if all_anki_notes: - for guid, fields in all_anki_notes.items(): - log_plain(guid + '::: ' + fields[FIELDS.TITLE], FILES.LOGS.FDN.ANKI_ORPHANS) - anki_dels.append(guid) - for guid, title in find_guids.items(): - log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKNOTES_ORPHANS) - anknotes_dels.append(guid) - - logs = [ - ["Orphan Anknotes DB Note(s)", - - len(anknotes_dels), - FILES.LOGS.FDN.ANKNOTES_ORPHANS, - "(not present in Evernote)" - - ], - - ["Orphan Anki Note(s)", - - len(anki_dels), - FILES.LOGS.FDN.ANKI_ORPHANS, - "(not present in Anknotes DB)" - - ], - - ["Unimported Evernote Note(s)", - - len(missing_evernote_notes), - FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES, - "(not present in Anknotes DB" - - ], - - ["Anknotes DB Title Mismatches", - - mismatch, - FILES.LOGS.FDN.ANKNOTES_TITLE_MISMATCHES - - ], - - ["Anki Title Mismatches", - - anki_mismatch, - FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES - - ] - ] - results = [ - [ - log[1], - log[0] if log[1] == 0 else '<a href="%s">%s</a>' % (get_log_full_path(log[2], as_url_link=True), log[0]), - log[3] if len(log) > 3 else '' - ] - for log in logs] - - # showInfo(str(results)) - - return { - "Summary": results, "AnknotesOrphans": anknotes_dels, "AnkiOrphans": anki_dels, - "MissingEvernoteNotes": missing_evernote_notes - } + """ + :param all_anki_notes: from Anki.get_evernote_guids_and_anki_fields_from_anki_note_ids() + :type : dict[str, dict[str, str]] + :return: + """ + + Error = sqlite.Error + + if not os.path.isfile(FILES.USER.TABLE_OF_CONTENTS_ENEX): + log_error('Unable to proceed with find_deleted_notes: TOC enex does not exist.', do_print=True) + return False + + enTableOfContents = file(FILES.USER.TABLE_OF_CONTENTS_ENEX, 'r').read() + # find = file(os.path.join(PATH, "powergrep-find.txt") , 'r').read().splitlines() + # replace = file(os.path.join(PATH, "powergrep-replace.txt") , 'r').read().replace('https://www.evernote.com/shard/s175/nl/19775535/' , '').splitlines() + + all_anknotes_notes = ankDB().all("SELECT guid, title, tagNames FROM %s " % TABLES.EVERNOTE.NOTES) + find_guids = {} + log_banner(' FIND DELETED EVERNOTE NOTES: UNIMPORTED EVERNOTE NOTES ', FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES) + log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKI NOTES ', FILES.LOGS.FDN.ANKI_ORPHANS) + log_banner(' FIND DELETED EVERNOTE NOTES: ORPHAN ANKNOTES DB ENTRIES ', FILES.LOGS.FDN.ANKNOTES_ORPHANS) + log_banner(' FIND DELETED EVERNOTE NOTES: ANKNOTES TITLE MISMATCHES ', FILES.LOGS.FDN.ANKNOTES_TITLE_MISMATCHES) + log_banner(' FIND DELETED EVERNOTE NOTES: ANKI TITLE MISMATCHES ', FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES) + log_banner(' FIND DELETED EVERNOTE NOTES: POSSIBLE TOC NOTES MISSING TAG ', + FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES + '_possibletoc') + anki_mismatch = 0 + is_toc_or_outline = [] + for line in all_anknotes_notes: + guid = line['guid'] + title = line['title'] + if not (',' + TAGS.TOC + ',' in line['tagNames']): + if title.upper() == title: + log_plain(guid + '::: %-50s: ' % line['tagNames'][1:-1] + title, + FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES + '_possibletoc', do_print=True) + + title = clean_title(title) + title_safe = str_safe(title) + find_guids[guid] = title + if all_anki_notes: + if guid in all_anki_notes: + find_title = all_anki_notes[guid][FIELDS.TITLE] + find_title_safe = str_safe(find_title) + if find_title_safe == title_safe: + del all_anki_notes[guid] + else: + log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES) + anki_mismatch += 1 + mismatch = 0 + missing_evernote_notes = [] + for enLink in find_evernote_links(enTableOfContents): + guid = enLink.Guid + title = clean_title(enLink.FullTitle) + title_safe = str_safe(title) + + if guid in find_guids: + find_title = find_guids[guid] + find_title_safe = str_safe(find_title) + if find_title_safe == title_safe: + del find_guids[guid] + else: + log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKNOTES_TITLE_MISMATCHES) + mismatch += 1 + else: + log_plain(guid + '::: ' + title, FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES) + missing_evernote_notes.append(guid) + + anki_dels = [] + anknotes_dels = [] + if all_anki_notes: + for guid, fields in all_anki_notes.items(): + log_plain(guid + '::: ' + fields[FIELDS.TITLE], FILES.LOGS.FDN.ANKI_ORPHANS) + anki_dels.append(guid) + for guid, title in find_guids.items(): + log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKNOTES_ORPHANS) + anknotes_dels.append(guid) + + logs = [ + ["Orphan Anknotes DB Note(s)", + + len(anknotes_dels), + FILES.LOGS.FDN.ANKNOTES_ORPHANS, + "(not present in Evernote)" + + ], + + ["Orphan Anki Note(s)", + + len(anki_dels), + FILES.LOGS.FDN.ANKI_ORPHANS, + "(not present in Anknotes DB)" + + ], + + ["Unimported Evernote Note(s)", + + len(missing_evernote_notes), + FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES, + "(not present in Anknotes DB" + + ], + + ["Anknotes DB Title Mismatches", + + mismatch, + FILES.LOGS.FDN.ANKNOTES_TITLE_MISMATCHES + + ], + + ["Anki Title Mismatches", + + anki_mismatch, + FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES + + ] + ] + results = [ + [ + log[1], + log[0] if log[1] == 0 else '<a href="%s">%s</a>' % (get_log_full_path(log[2], as_url_link=True), log[0]), + log[3] if len(log) > 3 else '' + ] + for log in logs] + + # showInfo(str(results)) + + return { + "Summary": results, "AnknotesOrphans": anknotes_dels, "AnkiOrphans": anki_dels, + "MissingEvernoteNotes": missing_evernote_notes + } diff --git a/anknotes/graphics.py b/anknotes/graphics.py index 3fe2295..106f51e 100644 --- a/anknotes/graphics.py +++ b/anknotes/graphics.py @@ -1,15 +1,15 @@ from anknotes.constants import * ### Anki Imports try: - from aqt.qt import QIcon, QPixmap + from aqt.qt import QIcon, QPixmap except: - pass + pass try: - icoEvernoteWeb = QIcon(FILES.GRAPHICS.ICON.EVERNOTE_WEB) - icoEvernoteArtcore = QIcon(FILES.GRAPHICS.ICON.EVERNOTE_ARTCORE) - icoTomato = QIcon(FILES.GRAPHICS.ICON.TOMATO) - imgEvernoteWeb = QPixmap(FILES.GRAPHICS.IMAGE.EVERNOTE_WEB, "PNG") - imgEvernoteWebMsgBox = imgEvernoteWeb.scaledToWidth(64) + icoEvernoteWeb = QIcon(FILES.GRAPHICS.ICON.EVERNOTE_WEB) + icoEvernoteArtcore = QIcon(FILES.GRAPHICS.ICON.EVERNOTE_ARTCORE) + icoTomato = QIcon(FILES.GRAPHICS.ICON.TOMATO) + imgEvernoteWeb = QPixmap(FILES.GRAPHICS.IMAGE.EVERNOTE_WEB, "PNG") + imgEvernoteWebMsgBox = imgEvernoteWeb.scaledToWidth(64) except: - pass + pass diff --git a/anknotes/html.py b/anknotes/html.py index bb4a723..afd7e2c 100644 --- a/anknotes/html.py +++ b/anknotes/html.py @@ -4,198 +4,206 @@ from anknotes.db import get_evernote_title_from_guid from anknotes.logging import log -try: from aqt import mw -except: pass +try: + from aqt import mw +except: + pass + class MLStripper(HTMLParser): - def __init__(self): - HTMLParser.__init__(self) - self.reset() - self.fed = [] + def __init__(self): + HTMLParser.__init__(self) + self.reset() + self.fed = [] - def handle_data(self, d): - self.fed.append(d) + def handle_data(self, d): + self.fed.append(d) - def get_data(self): - return ''.join(self.fed) + def get_data(self): + return ''.join(self.fed) def strip_tags(html): - if html is None: return None - html = html.replace('&', '__DONT_STRIP_HTML_ENTITIES___') - s = MLStripper() - s.feed(html) - html = s.get_data() - html = html.replace('__DONT_STRIP_HTML_ENTITIES___', '&') - return html - # s = MLStripper() - # s.feed(html) - # return s.get_data() + if html is None: return None + html = html.replace('&', '__DONT_STRIP_HTML_ENTITIES___') + s = MLStripper() + s.feed(html) + html = s.get_data() + html = html.replace('__DONT_STRIP_HTML_ENTITIES___', '&') + return html + + +# s = MLStripper() +# s.feed(html) +# return s.get_data() def strip_tags_and_new_lines(html): - if html is None: return None - return re.sub(r'[\r\n]+', ' ', strip_tags(html)) + if html is None: return None + return re.sub(r'[\r\n]+', ' ', strip_tags(html)) + + __text_escape_phrases__ = u'&|&|\'|'|"|"|>|>|<|<'.split('|') def escape_text(title): - global __text_escape_phrases__ - for i in range(0, len(__text_escape_phrases__), 2): - title = title.replace(__text_escape_phrases__[i], __text_escape_phrases__[i + 1]) - return title + global __text_escape_phrases__ + for i in range(0, len(__text_escape_phrases__), 2): + title = title.replace(__text_escape_phrases__[i], __text_escape_phrases__[i + 1]) + return title def unescape_text(title, try_decoding=False): - title_orig = title - global __text_escape_phrases__ - if try_decoding: title = title.decode('utf-8') - try: - for i in range(0, len(__text_escape_phrases__), 2): - title = title.replace(__text_escape_phrases__[i + 1], __text_escape_phrases__[i]) - title = title.replace(u" ", u" ") - except: - if try_decoding: raise UnicodeError - title_new = unescape_text(title, True) - log(title + '\n' + title_new + '\n\n', 'unicode') - return title_new - return title + title_orig = title + global __text_escape_phrases__ + if try_decoding: title = title.decode('utf-8') + try: + for i in range(0, len(__text_escape_phrases__), 2): + title = title.replace(__text_escape_phrases__[i + 1], __text_escape_phrases__[i]) + title = title.replace(u" ", u" ") + except: + if try_decoding: raise UnicodeError + title_new = unescape_text(title, True) + log(title + '\n' + title_new + '\n\n', 'unicode') + return title_new + return title def clean_title(title): - if isinstance(title, str): - title = unicode(title, 'utf-8') - title = unescape_text(title) - if isinstance(title, str): - title = unicode(title, 'utf-8') - title = title.replace(u'\xa0', ' ') - return title + if isinstance(title, str): + title = unicode(title, 'utf-8') + title = unescape_text(title) + if isinstance(title, str): + title = unicode(title, 'utf-8') + title = title.replace(u'\xa0', ' ') + return title def generate_evernote_url(guid): - ids = get_evernote_account_ids() - return u'evernote:///view/%s/%s/%s/%s/' % (ids.uid, ids.shard, guid, guid) + ids = get_evernote_account_ids() + return u'evernote:///view/%s/%s/%s/%s/' % (ids.uid, ids.shard, guid, guid) def generate_evernote_link_by_type(guid, title=None, link_type=None, value=None, escape=True): - url = generate_evernote_url(guid) - if not title: title = get_evernote_title_from_guid(guid) - if escape: title = escape_text(title) - style = generate_evernote_html_element_style_attribute(link_type, value) - html = u"""<a href="%s"><span style="%s">%s</span></a>""" % (url, style, title) - # print html - return html + url = generate_evernote_url(guid) + if not title: title = get_evernote_title_from_guid(guid) + if escape: title = escape_text(title) + style = generate_evernote_html_element_style_attribute(link_type, value) + html = u"""<a href="%s"><span style="%s">%s</span></a>""" % (url, style, title) + # print html + return html def generate_evernote_link(guid, title=None, value=None, escape=True): - return generate_evernote_link_by_type(guid, title, 'Links', value, escape=escape) + return generate_evernote_link_by_type(guid, title, 'Links', value, escape=escape) def generate_evernote_link_by_level(guid, title=None, value=None, escape=True): - return generate_evernote_link_by_type(guid, title, 'Levels', value, escape=escape) + return generate_evernote_link_by_type(guid, title, 'Levels', value, escape=escape) + def generate_evernote_html_element_style_attribute(link_type, value, bold=True, group=None): - global evernote_link_colors - colors = None - if link_type in evernote_link_colors: - color_types = evernote_link_colors[link_type] - if link_type is 'Levels': - if not value: value = 1 - if not group: group = 'OL' if isinstance(value, int) else 'Modifiers' - if not value in color_types[group]: group = 'Headers' - if value in color_types[group]: - colors = color_types[group][value] - elif link_type is 'Links': - if not value: value = 'Default' - if value in color_types: - colors = color_types[value] - if not colors: - colors = evernote_link_colors['Default'] - colorDefault = colors - if not isinstance(colorDefault, str) and not isinstance(colorDefault, unicode): - colorDefault = colorDefault['Default'] - if not colorDefault[-1] is ';': colorDefault += ';' - style = 'color: ' + colorDefault - if bold: style += 'font-weight:bold;' - return style + global evernote_link_colors + colors = None + if link_type in evernote_link_colors: + color_types = evernote_link_colors[link_type] + if link_type is 'Levels': + if not value: value = 1 + if not group: group = 'OL' if isinstance(value, int) else 'Modifiers' + if not value in color_types[group]: group = 'Headers' + if value in color_types[group]: + colors = color_types[group][value] + elif link_type is 'Links': + if not value: value = 'Default' + if value in color_types: + colors = color_types[value] + if not colors: + colors = evernote_link_colors['Default'] + colorDefault = colors + if not isinstance(colorDefault, str) and not isinstance(colorDefault, unicode): + colorDefault = colorDefault['Default'] + if not colorDefault[-1] is ';': colorDefault += ';' + style = 'color: ' + colorDefault + if bold: style += 'font-weight:bold;' + return style def generate_evernote_span(title=None, element_type=None, value=None, guid=None, bold=True, escape=True): - assert title or guid - if not title: title = get_evernote_title_from_guid(guid) - if escape: title = escape_text(title) - style = generate_evernote_html_element_style_attribute(element_type, value, bold) - html = u"""<span style="%s">%s</span>""" % (style, title) - return html + assert title or guid + if not title: title = get_evernote_title_from_guid(guid) + if escape: title = escape_text(title) + style = generate_evernote_html_element_style_attribute(element_type, value, bold) + html = u"""<span style="%s">%s</span>""" % (style, title) + return html evernote_link_colors = { - 'Levels': { - 'OL': { - 1: { - 'Default': 'rgb(106, 0, 129);', - 'Hover': 'rgb(168, 0, 204);' - }, - 2: { - 'Default': 'rgb(235, 0, 115);', - 'Hover': 'rgb(255, 94, 174);' - }, - 3: { - 'Default': 'rgb(186, 0, 255);', - 'Hover': 'rgb(213, 100, 255);' - }, - 4: { - 'Default': 'rgb(129, 182, 255);', - 'Hover': 'rgb(36, 130, 255);' - }, - 5: { - 'Default': 'rgb(232, 153, 220);', - 'Hover': 'rgb(142, 32, 125);' - }, - 6: { - 'Default': 'rgb(201, 213, 172);', - 'Hover': 'rgb(130, 153, 77);' - }, - 7: { - 'Default': 'rgb(231, 179, 154);', - 'Hover': 'rgb(215, 129, 87);' - }, - 8: { - 'Default': 'rgb(249, 136, 198);', - 'Hover': 'rgb(215, 11, 123);' - } - }, - 'Headers': { - 'Auto TOC': 'rgb(11, 59, 225);' - }, - 'Modifiers': { - 'Orange': 'rgb(222, 87, 0);', - 'Orange (Light)': 'rgb(250, 122, 0);', - 'Dark Red/Pink': 'rgb(164, 15, 45);', - 'Pink Alternative LVL1:': 'rgb(188, 0, 88);' - } - }, - 'Titles': { - 'Field Title Prompt': 'rgb(169, 0, 48);' - }, - 'Links': { - 'See Also': { - 'Default': 'rgb(45, 79, 201);', - 'Hover': 'rgb(108, 132, 217);' - }, - 'TOC': { - 'Default': 'rgb(173, 0, 0);', - 'Hover': 'rgb(196, 71, 71);' - }, - 'Outline': { - 'Default': 'rgb(105, 170, 53);', - 'Hover': 'rgb(135, 187, 93);' - }, - 'AnkNotes': { - 'Default': 'rgb(30, 155, 67);', - 'Hover': 'rgb(107, 226, 143);' - } - } +'Levels': { +'OL': { +1: { +'Default': 'rgb(106, 0, 129);', +'Hover': 'rgb(168, 0, 204);' +}, +2: { +'Default': 'rgb(235, 0, 115);', +'Hover': 'rgb(255, 94, 174);' +}, +3: { +'Default': 'rgb(186, 0, 255);', +'Hover': 'rgb(213, 100, 255);' +}, +4: { +'Default': 'rgb(129, 182, 255);', +'Hover': 'rgb(36, 130, 255);' +}, +5: { +'Default': 'rgb(232, 153, 220);', +'Hover': 'rgb(142, 32, 125);' +}, +6: { +'Default': 'rgb(201, 213, 172);', +'Hover': 'rgb(130, 153, 77);' +}, +7: { +'Default': 'rgb(231, 179, 154);', +'Hover': 'rgb(215, 129, 87);' +}, +8: { +'Default': 'rgb(249, 136, 198);', +'Hover': 'rgb(215, 11, 123);' +} +}, +'Headers': { +'Auto TOC': 'rgb(11, 59, 225);' +}, +'Modifiers': { +'Orange': 'rgb(222, 87, 0);', +'Orange (Light)': 'rgb(250, 122, 0);', +'Dark Red/Pink': 'rgb(164, 15, 45);', +'Pink Alternative LVL1:': 'rgb(188, 0, 88);' +} +}, +'Titles': { +'Field Title Prompt': 'rgb(169, 0, 48);' +}, +'Links': { +'See Also': { +'Default': 'rgb(45, 79, 201);', +'Hover': 'rgb(108, 132, 217);' +}, +'TOC': { +'Default': 'rgb(173, 0, 0);', +'Hover': 'rgb(196, 71, 71);' +}, +'Outline': { +'Default': 'rgb(105, 170, 53);', +'Hover': 'rgb(135, 187, 93);' +}, +'AnkNotes': { +'Default': 'rgb(30, 155, 67);', +'Hover': 'rgb(107, 226, 143);' +} +} } evernote_link_colors['Default'] = evernote_link_colors['Links']['Outline'] @@ -205,56 +213,68 @@ def generate_evernote_span(title=None, element_type=None, value=None, guid=None, def get_evernote_account_ids(): - global enAccountIDs - if not enAccountIDs or not enAccountIDs.Valid: - enAccountIDs = EvernoteAccountIDs() - return enAccountIDs + global enAccountIDs + if not enAccountIDs or not enAccountIDs.Valid: + enAccountIDs = EvernoteAccountIDs() + return enAccountIDs + def tableify_column(column): - return str(column).replace('\n', '\n<BR>').replace(' ', '  ') + return str(column).replace('\n', '\n<BR>').replace(' ', '  ') + def tableify_lines(rows, columns=None, tr_index_offset=0, return_html=True): - if columns is None: columns = [] - elif not isinstance(columns, list): columns = [columns] - trs = ['<tr class="tr%d%s">%s\n</tr>\n' % (i_row, ' alt' if i_row % 2 is 0 else ' std', ''.join(['\n <td class="td%d%s">%s</td>' % (i_col+1, ' alt' if i_col % 2 is 0 else ' std', tableify_column(column)) for i_col, column in enumerate(row if isinstance(row, list) else row.split('|'))])) for i_row, row in enumerate(columns + rows)] - if return_html: - return "<table cellspacing='0' style='border: 1px solid black;border-collapse: collapse;'>\n%s</table>" % ''.join(trs) - return trs + if columns is None: + columns = [] + elif not isinstance(columns, list): + columns = [columns] + trs = ['<tr class="tr%d%s">%s\n</tr>\n' % (i_row, ' alt' if i_row % 2 is 0 else ' std', ''.join( + ['\n <td class="td%d%s">%s</td>' % (i_col + 1, ' alt' if i_col % 2 is 0 else ' std', tableify_column(column)) + for i_col, column in enumerate(row if isinstance(row, list) else row.split('|'))])) for i_row, row in + enumerate(columns + rows)] + if return_html: + return "<table cellspacing='0' style='border: 1px solid black;border-collapse: collapse;'>\n%s</table>" % ''.join( + trs) + return trs + class EvernoteAccountIDs: - uid = SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE - shard = SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE - - @property - def Valid(self): - return self.is_valid() - - def is_valid(self, uid=None, shard=None): - if uid is None: uid = self.uid - if shard is None: shard = self.shard - if not uid or not shard: return False - if uid == '0' or uid == SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE or not unicode(uid).isnumeric(): return False - if shard == 's999' or uid == SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE or shard[0] != 's' or not unicode(shard[1:]).isnumeric(): return False - return True - def __init__(self, uid=None, shard=None): - if uid and shard: - if self.update(uid, shard): return - try: - self.uid = mw.col.conf.get(SETTINGS.EVERNOTE.ACCOUNT.UID, SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE) - self.shard = mw.col.conf.get(SETTINGS.EVERNOTE.ACCOUNT.SHARD, SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE) - if self.Valid: return - except: - pass - self.uid = SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE - self.shard = SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE - - def update(self, uid, shard): - if not self.is_valid(uid, shard): return False - try: - mw.col.conf[SETTINGS.EVERNOTE.ACCOUNT.UID] = uid - mw.col.conf[SETTINGS.EVERNOTE.ACCOUNT.SHARD] = shard - except: - return False - self.uid = uid - self.shard = shard - return self.Valid + uid = SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE + shard = SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE + + @property + def Valid(self): + return self.is_valid() + + def is_valid(self, uid=None, shard=None): + if uid is None: uid = self.uid + if shard is None: shard = self.shard + if not uid or not shard: return False + if uid == '0' or uid == SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE or not unicode( + uid).isnumeric(): return False + if shard == 's999' or uid == SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE or shard[0] != 's' or not unicode( + shard[1:]).isnumeric(): return False + return True + + def __init__(self, uid=None, shard=None): + if uid and shard: + if self.update(uid, shard): return + try: + self.uid = mw.col.conf.get(SETTINGS.EVERNOTE.ACCOUNT.UID, SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE) + self.shard = mw.col.conf.get(SETTINGS.EVERNOTE.ACCOUNT.SHARD, SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE) + if self.Valid: return + except: + pass + self.uid = SETTINGS.EVERNOTE.ACCOUNT.UID_DEFAULT_VALUE + self.shard = SETTINGS.EVERNOTE.ACCOUNT.SHARD_DEFAULT_VALUE + + def update(self, uid, shard): + if not self.is_valid(uid, shard): return False + try: + mw.col.conf[SETTINGS.EVERNOTE.ACCOUNT.UID] = uid + mw.col.conf[SETTINGS.EVERNOTE.ACCOUNT.SHARD] = shard + except: + return False + self.uid = uid + self.shard = shard + return self.Valid diff --git a/anknotes/logging.py b/anknotes/logging.py index 0250996..f175ac4 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -14,627 +14,743 @@ # Anki Imports try: - from aqt import mw - from aqt.utils import tooltip - from aqt.qt import QMessageBox, QPushButton, QSizePolicy, QSpacerItem, QGridLayout, QLayout + from aqt import mw + from aqt.utils import tooltip + from aqt.qt import QMessageBox, QPushButton, QSizePolicy, QSpacerItem, QGridLayout, QLayout except: - pass + pass def str_safe(strr, prefix=''): - try: - strr = str((prefix + strr.__repr__())) - except: - strr = str((prefix + strr.__repr__().encode('utf8', 'replace'))) - return strr + try: + strr = str((prefix + strr.__repr__())) + except: + strr = str((prefix + strr.__repr__().encode('utf8', 'replace'))) + return strr def print_safe(strr, prefix=''): - print str_safe(strr, prefix) + print + str_safe(strr, prefix) def show_tooltip(text, time_out=7000, delay=None, do_log=False): - if do_log: log(text) - if delay: - try: return mw.progress.timer(delay, lambda: tooltip(text, time_out), False) - except: pass - tooltip(text, time_out) + if do_log: log(text) + if delay: + try: + return mw.progress.timer(delay, lambda: tooltip(text, time_out), False) + except: + pass + tooltip(text, time_out) + + def counts_as_str(count, max=None): - from anknotes.counters import Counter - if isinstance(count, Counter): count = count.val - if isinstance(max, Counter): max = max.val - if max is None or max <= 0: return str(count).center(3) - if count == max: return "All %s" % str(count).center(3) - return "Total %s of %s" % (str(count).center(3), str(max).center(3)) - -def show_report(title, header=None, log_lines=None, delay=None, log_header_prefix = ' '*5, filename=None, blank_line_before=True, blank_line_after=True, hr_if_empty=False): - if log_lines is None: log_lines = [] - if header is None: header = [] - lines = [] - for line in ('<BR>'.join(header) if isinstance(header, list) else header).split('<BR>') + ('<BR>'.join(log_lines).split('<BR>') if log_lines else []): - level = 0 - while line and line[level] is '-': level += 1 - lines.append('\t'*level + ('\t\t- ' if lines else '') + line[level:]) - if len(lines) > 1: lines[0] += ': ' - log_text = '<BR>'.join(lines) - if not header and not log_lines: - i=title.find('> ') - show_tooltip(title[0 if i < 0 else i + 2:], delay=delay) - else: show_tooltip(log_text.replace('\t', '  '*4), delay=delay) - if blank_line_before: log_blank(filename=filename) - log(title, filename=filename) - if len(lines) == 1 and not lines[0]: - if hr_if_empty: log(" " + "-" * ANKNOTES.FORMATTING.LINE_LENGTH, timestamp=False, filename=filename) - return - log(" " + "-" * ANKNOTES.FORMATTING.LINE_LENGTH + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), timestamp=False, replace_newline=True, filename=filename) - if blank_line_after: log_blank(filename=filename) - - -def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0, cancelButton=False, richText=False, minHeight=None, minWidth=400, styleSheet=None, convertNewLines=True): - global imgEvernoteWebMsgBox, icoEvernoteArtcore, icoEvernoteWeb - msgDefaultButton = QPushButton(icoEvernoteArtcore, "Okay!", mw) - - if not styleSheet: - styleSheet = file(FILES.ANCILLARY.CSS_QMESSAGEBOX, 'r').read() - - if not isinstance(message, str) and not isinstance(message, unicode): - message = str(message) - - if richText: - textFormat = 1 - # message = message.replace('\n', '<BR>\n') - message = '<style>\n%s</style>\n\n%s' % (styleSheet, message) - global messageBox - messageBox = QMessageBox() - messageBox.addButton(msgDefaultButton, QMessageBox.AcceptRole) - if cancelButton: - msgCancelButton = QPushButton(icoTomato, "No Thanks", mw) - messageBox.addButton(msgCancelButton, QMessageBox.RejectRole) - messageBox.setDefaultButton(msgDefaultButton) - messageBox.setIconPixmap(imgEvernoteWebMsgBox) - messageBox.setTextFormat(textFormat) - - # message = ' %s %s' % (styleSheet, message) - # log_plain(message, 'showInfo', clear=True) - messageBox.setWindowIcon(icoEvernoteWeb) - messageBox.setWindowIconText("Anknotes") - messageBox.setText(message) - messageBox.setWindowTitle(title) - # if minHeight: - # messageBox.setMinimumHeight(minHeight) - # messageBox.setMinimumWidth(minWidth) - # - # messageBox.setFixedWidth(1000) - hSpacer = QSpacerItem(minWidth, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) - - layout = messageBox.layout() - """:type : QGridLayout """ - # layout.addItem(hSpacer, layout.rowCount() + 1, 0, 1, layout.columnCount()) - layout.addItem(hSpacer, layout.rowCount() + 1, 0, 1, layout.columnCount()) - # messageBox.setStyleSheet(styleSheet) - - - ret = messageBox.exec_() - if not cancelButton: - return True - if messageBox.clickedButton() == msgCancelButton or messageBox.clickedButton() == 0: - return False - return True + from anknotes.counters import Counter + + if isinstance(count, Counter): count = count.val + if isinstance(max, Counter): max = max.val + if max is None or max <= 0: return str(count).center(3) + if count == max: return "All %s" % str(count).center(3) + return "Total %s of %s" % (str(count).center(3), str(max).center(3)) + + +def show_report(title, header=None, log_lines=None, delay=None, log_header_prefix=' ' * 5, filename=None, + blank_line_before=True, blank_line_after=True, hr_if_empty=False): + if log_lines is None: log_lines = [] + if header is None: header = [] + lines = [] + for line in ('<BR>'.join(header) if isinstance(header, list) else header).split('<BR>') + ( + '<BR>'.join(log_lines).split('<BR>') if log_lines else []): + level = 0 + while line and line[level] is '-': level += 1 + lines.append('\t' * level + ('\t\t- ' if lines else '') + line[level:]) + if len(lines) > 1: lines[0] += ': ' + log_text = '<BR>'.join(lines) + if not header and not log_lines: + i = title.find('> ') + show_tooltip(title[0 if i < 0 else i + 2:], delay=delay) + else: + show_tooltip(log_text.replace('\t', '  ' * 4), delay=delay) + if blank_line_before: log_blank(filename=filename) + log(title, filename=filename) + if len(lines) == 1 and not lines[0]: + if hr_if_empty: log(" " + "-" * ANKNOTES.FORMATTING.LINE_LENGTH, timestamp=False, filename=filename) + return + log(" " + "-" * ANKNOTES.FORMATTING.LINE_LENGTH + '\n' + log_header_prefix + log_text.replace('<BR>', '\n'), + timestamp=False, replace_newline=True, filename=filename) + if blank_line_after: log_blank(filename=filename) + + +def showInfo(message, title="Anknotes: Evernote Importer for Anki", textFormat=0, cancelButton=False, richText=False, + minHeight=None, minWidth=400, styleSheet=None, convertNewLines=True): + global imgEvernoteWebMsgBox, icoEvernoteArtcore, icoEvernoteWeb + msgDefaultButton = QPushButton(icoEvernoteArtcore, "Okay!", mw) + + if not styleSheet: + styleSheet = file(FILES.ANCILLARY.CSS_QMESSAGEBOX, 'r').read() + + if not isinstance(message, str) and not isinstance(message, unicode): + message = str(message) + + if richText: + textFormat = 1 + # message = message.replace('\n', '<BR>\n') + message = '<style>\n%s</style>\n\n%s' % (styleSheet, message) + global messageBox + messageBox = QMessageBox() + messageBox.addButton(msgDefaultButton, QMessageBox.AcceptRole) + if cancelButton: + msgCancelButton = QPushButton(icoTomato, "No Thanks", mw) + messageBox.addButton(msgCancelButton, QMessageBox.RejectRole) + messageBox.setDefaultButton(msgDefaultButton) + messageBox.setIconPixmap(imgEvernoteWebMsgBox) + messageBox.setTextFormat(textFormat) + + # message = ' %s %s' % (styleSheet, message) + # log_plain(message, 'showInfo', clear=True) + messageBox.setWindowIcon(icoEvernoteWeb) + messageBox.setWindowIconText("Anknotes") + messageBox.setText(message) + messageBox.setWindowTitle(title) + # if minHeight: + # messageBox.setMinimumHeight(minHeight) + # messageBox.setMinimumWidth(minWidth) + # + # messageBox.setFixedWidth(1000) + hSpacer = QSpacerItem(minWidth, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) + + layout = messageBox.layout() + """:type : QGridLayout """ + # layout.addItem(hSpacer, layout.rowCount() + 1, 0, 1, layout.columnCount()) + layout.addItem(hSpacer, layout.rowCount() + 1, 0, 1, layout.columnCount()) + # messageBox.setStyleSheet(styleSheet) + + + ret = messageBox.exec_() + if not cancelButton: + return True + if messageBox.clickedButton() == msgCancelButton or messageBox.clickedButton() == 0: + return False + return True + def diffify(content, split=True): - for tag in [u'div', u'ol', u'ul', u'li', u'span']: - content = content.replace(u"<" + tag, u"\n<" + tag).replace(u"</%s>" % tag, u"</%s>\n" % tag) - content = re.sub(r'[\r\n]+', u'\n', content) - return content.splitlines() if split else content + for tag in [u'div', u'ol', u'ul', u'li', u'span']: + content = content.replace(u"<" + tag, u"\n<" + tag).replace(u"</%s>" % tag, u"</%s>\n" % tag) + content = re.sub(r'[\r\n]+', u'\n', content) + return content.splitlines() if split else content + + def generate_diff(value_original, value): - try: return '\n'.join(list(difflib.unified_diff(diffify(value_original), diffify(value), lineterm=''))) - except: pass - try: return '\n'.join( - list(difflib.unified_diff(diffify(value_original.decode('utf-8')), diffify(value), lineterm=''))) - except: pass - try: return '\n'.join( - list(difflib.unified_diff(diffify(value_original), diffify(value.decode('utf-8')), lineterm=''))) - except: pass - try: return '\n'.join(list( - difflib.unified_diff(diffify(value_original.decode('utf-8')), diffify(value.decode('utf-8')), lineterm=''))) - except: raise + try: + return '\n'.join(list(difflib.unified_diff(diffify(value_original), diffify(value), lineterm=''))) + except: + pass + try: + return '\n'.join( + list(difflib.unified_diff(diffify(value_original.decode('utf-8')), diffify(value), lineterm=''))) + except: + pass + try: + return '\n'.join( + list(difflib.unified_diff(diffify(value_original), diffify(value.decode('utf-8')), lineterm=''))) + except: + pass + try: + return '\n'.join(list( + difflib.unified_diff(diffify(value_original.decode('utf-8')), diffify(value.decode('utf-8')), lineterm=''))) + except: + raise def PadList(lst, length=ANKNOTES.FORMATTING.LIST_PAD): - newLst = [] - for val in lst: - if isinstance(val, list): newLst.append(PadList(val, length)) - else: newLst.append(val.center(length)) - return newLst + newLst = [] + for val in lst: + if isinstance(val, list): + newLst.append(PadList(val, length)) + else: + newLst.append(val.center(length)) + return newLst + + def JoinList(lst, joiners='\n', pad=0, depth=1): - if isinstance(joiners, str) or isinstance(joiners, unicode): joiners = [joiners] - strr = '' - if pad and (isinstance(lst, str) or isinstance(lst, unicode)): return lst.center(pad) - if not lst or not isinstance(lst, list): return lst - delimit=joiners[min(len(joiners), depth)-1] - for val in lst: - if strr: strr += delimit - strr += JoinList(val, joiners, pad, depth+1) - return strr - -def PadLines(content, line_padding=ANKNOTES.FORMATTING.LINE_PADDING_HEADER, line_padding_plus=0, line_padding_header='', pad_char=' ', **kwargs): - if not line_padding and not line_padding_plus and not line_padding_header: return content - if not line_padding: line_padding = line_padding_plus; line_padding_plus=True - if str(line_padding).isdigit(): line_padding = pad_char * int(line_padding) - if line_padding_header: content = line_padding_header + content; line_padding_plus = len(line_padding_header) + 1 - elif line_padding_plus is True: line_padding_plus = content.find('\n') - if str(line_padding_plus).isdigit(): line_padding_plus = pad_char * int(line_padding_plus) - return line_padding + content.replace('\n', '\n' + line_padding + line_padding_plus) - -def item_to_list(item, list_from_unknown=True,chrs=''): - if isinstance(item, list): return item - if item and (isinstance(item, unicode) or isinstance(item, str)): - for c in chrs: item=item.replace(c, '|') - return item.split('|') - if list_from_unknown: return [item] - return item + if isinstance(joiners, str) or isinstance(joiners, unicode): joiners = [joiners] + strr = '' + if pad and (isinstance(lst, str) or isinstance(lst, unicode)): return lst.center(pad) + if not lst or not isinstance(lst, list): return lst + delimit = joiners[min(len(joiners), depth) - 1] + for val in lst: + if strr: strr += delimit + strr += JoinList(val, joiners, pad, depth + 1) + return strr + + +def PadLines(content, line_padding=ANKNOTES.FORMATTING.LINE_PADDING_HEADER, line_padding_plus=0, line_padding_header='', + pad_char=' ', **kwargs): + if not line_padding and not line_padding_plus and not line_padding_header: return content + if not line_padding: line_padding = line_padding_plus; line_padding_plus = True + if str(line_padding).isdigit(): line_padding = pad_char * int(line_padding) + if line_padding_header: + content = line_padding_header + content; line_padding_plus = len(line_padding_header) + 1 + elif line_padding_plus is True: + line_padding_plus = content.find('\n') + if str(line_padding_plus).isdigit(): line_padding_plus = pad_char * int(line_padding_plus) + return line_padding + content.replace('\n', '\n' + line_padding + line_padding_plus) + + +def item_to_list(item, list_from_unknown=True, chrs=''): + if isinstance(item, list): return item + if item and (isinstance(item, unicode) or isinstance(item, str)): + for c in chrs: item = item.replace(c, '|') + return item.split('|') + if list_from_unknown: return [item] + return item + + def key_transform(keys, key): - if keys is None: keys = self.keys() - key = key.strip() - for k in keys: - if k.lower() == key.lower(): return k - return key - + if keys is None: keys = self.keys() + key = key.strip() + for k in keys: + if k.lower() == key.lower(): return k + return key + + def get_kwarg(func_kwargs, key, **kwargs): - kwargs['update_kwargs'] = False - return process_kwarg(func_kwargs, key, **kwargs) + kwargs['update_kwargs'] = False + return process_kwarg(func_kwargs, key, **kwargs) + def process_kwarg(kwargs, key, default=None, replace_none_type=True, update_kwargs=True, return_value=True): - key = key_transform(kwargs.keys(), key) - if key not in kwargs: return (kwargs, default) if update_kwargs else default - val = kwargs[key] - if val is None and replace_none_type: val = default - if not update_kwargs: return val - del kwargs[key] - return kwargs, val + key = key_transform(kwargs.keys(), key) + if key not in kwargs: return (kwargs, default) if update_kwargs else default + val = kwargs[key] + if val is None and replace_none_type: val = default + if not update_kwargs: return val + del kwargs[key] + return kwargs, val + + def process_kwargs(kwargs, get_args=None, set_dict=None, name=None, update_kwargs=True): - keys = kwargs.keys() - for key, value in set_dict.items() if set_dict else []: - key = key_transform(keys, key) - if not key in kwargs: kwargs[key]=value - kwargs = DictCaseInsensitive(kwargs, label=name) - if not get_args: return kwargs - keys = kwargs.keys() - gets = [] - for args in get_args: - for arg in args: - if len(arg) is 1 and isinstance(arg[0], list): arg = arg[0] - result = process_kwarg(kwargs, arg[0], arg[1], update_kwargs=update_kwargs) - if update_kwargs: kwargs = result[0]; result = result[1] - gets += [result] - if update_kwargs: return [kwargs] + gets - return gets + keys = kwargs.keys() + for key, value in set_dict.items() if set_dict else []: + key = key_transform(keys, key) + if not key in kwargs: kwargs[key] = value + kwargs = DictCaseInsensitive(kwargs, label=name) + if not get_args: return kwargs + keys = kwargs.keys() + gets = [] + for args in get_args: + for arg in args: + if len(arg) is 1 and isinstance(arg[0], list): arg = arg[0] + result = process_kwarg(kwargs, arg[0], arg[1], update_kwargs=update_kwargs) + if update_kwargs: kwargs = result[0]; result = result[1] + gets += [result] + if update_kwargs: return [kwargs] + gets + return gets + def __get_args__(args, func_kwargs, *args_list, **kwargs_): - kwargs = DictCaseInsensitive({'suffix_type_to_name':True, 'max_args':-1, 'default_value':None, 'return_expanded':True, 'return_values_only':False}) - kwargs.update(kwargs_) - max_args = kwargs.max_args - args = list(args) - # names = item_to_list(names, False) - # if isinstance(names, list): names = [[name, None] for name in names] - # else: names = names.items() - results = DictCaseInsensitive() - max_args = len(args) if max_args < 1 else min(len(args), max_args) - values=[] - args_to_del=[] - get_names = [[names[i*2:i*2+2] for i in range(0, len(names)/2)] if isinstance(names, list) else [[name, None] for name in item_to_list(names)] for names in args_list] - - for get_name in get_names: - for get_name_item in get_name: - if len(get_name_item) is 1 and isinstance(get_name_item[0], list): get_name_item = get_name_item[0] - name = get_name_item[0] - types=get_name_item[1] - # print "Name: %s, Types: %s" % (name, str(types[0])) - name = name.replace('*', '') - types = item_to_list(types) - is_none_type = types[0] is None - key = name + ( '_' + types[0].__name__) if kwargs.suffix_type_to_name and not is_none_type else '' - key = key_transform(func_kwargs.keys(), key) - result = DictCaseInsensitive(Match=False, MatchedKWArg=False, MatchedArg=False, Name=key, value=kwargs.default_value) - if key in func_kwargs: - result.value = func_kwargs[key] - del func_kwargs[key] - result.Match = True - result.MatchedKWArg = True - continue - if is_none_type: continue - for i in range(0, max_args): - if i in args_to_del: continue - arg = args[i] - for t in types: - if not isinstance(arg, t): continue - result.value = arg - result.Match = True - result.MatchedArg = True - args_to_del.append(i) - break - if result.Match: break - values.append(result.value) - results[name] = result - args = [x for i, x in enumerate(args) if i not in args_to_del] - results.func_kwargs = func_kwargs - results.args = args - if kwargs.return_values_only: return values - if kwargs.return_expanded: return [args, func_kwargs] + values - return results + kwargs = DictCaseInsensitive( + {'suffix_type_to_name': True, 'max_args': -1, 'default_value': None, 'return_expanded': True, + 'return_values_only': False}) + kwargs.update(kwargs_) + max_args = kwargs.max_args + args = list(args) + # names = item_to_list(names, False) + # if isinstance(names, list): names = [[name, None] for name in names] + # else: names = names.items() + results = DictCaseInsensitive() + max_args = len(args) if max_args < 1 else min(len(args), max_args) + values = [] + args_to_del = [] + get_names = [ + [names[i * 2:i * 2 + 2] for i in range(0, len(names) / 2)] if isinstance(names, list) else [[name, None] for + name in + item_to_list(names)] + for names in args_list] + + for get_name in get_names: + for get_name_item in get_name: + if len(get_name_item) is 1 and isinstance(get_name_item[0], list): get_name_item = get_name_item[0] + name = get_name_item[0] + types = get_name_item[1] + # print "Name: %s, Types: %s" % (name, str(types[0])) + name = name.replace('*', '') + types = item_to_list(types) + is_none_type = types[0] is None + key = name + ( '_' + types[0].__name__) if kwargs.suffix_type_to_name and not is_none_type else '' + key = key_transform(func_kwargs.keys(), key) + result = DictCaseInsensitive(Match=False, MatchedKWArg=False, MatchedArg=False, Name=key, + value=kwargs.default_value) + if key in func_kwargs: + result.value = func_kwargs[key] + del func_kwargs[key] + result.Match = True + result.MatchedKWArg = True + continue + if is_none_type: continue + for i in range(0, max_args): + if i in args_to_del: continue + arg = args[i] + for t in types: + if not isinstance(arg, t): continue + result.value = arg + result.Match = True + result.MatchedArg = True + args_to_del.append(i) + break + if result.Match: break + values.append(result.value) + results[name] = result + args = [x for i, x in enumerate(args) if i not in args_to_del] + results.func_kwargs = func_kwargs + results.args = args + if kwargs.return_values_only: return values + if kwargs.return_expanded: return [args, func_kwargs] + values + return results + + def __get_default_listdict_args__(args, kwargs, name): - results_expanded = __get_args__(args, kwargs, [name + '*', [list, str, unicode], name , [dict, DictCaseInsensitive]]) - # results_expanded[2] = item_to_list(results_expanded[2], chrs=',') - if results_expanded[2] is None: results_expanded[2] = [] - if results_expanded[3] is None: results_expanded[3] = {} - return results_expanded + results_expanded = __get_args__(args, kwargs, [name + '*', [list, str, unicode], name, [dict, DictCaseInsensitive]]) + # results_expanded[2] = item_to_list(results_expanded[2], chrs=',') + if results_expanded[2] is None: results_expanded[2] = [] + if results_expanded[3] is None: results_expanded[3] = {} + return results_expanded + def get_kwarg_values(func_kwargs, *args, **kwargs): - kwargs['update_kwargs'] = False - return get_kwargs(func_kwargs, *args, **kwargs) + kwargs['update_kwargs'] = False + return get_kwargs(func_kwargs, *args, **kwargs) + def get_kwargs(func_kwargs, *args_list, **kwargs): - lst = [[args[i*2:i*2+2] for i in range(0, len(args)/2)] if isinstance(args, list) else [[arg, None] for arg in item_to_list(args)] for args in args_list] - return process_kwargs(func_kwargs, get_args=lst, **kwargs) + lst = [ + [args[i * 2:i * 2 + 2] for i in range(0, len(args) / 2)] if isinstance(args, list) else [[arg, None] for arg in + item_to_list(args)] for + args in args_list] + return process_kwargs(func_kwargs, get_args=lst, **kwargs) + def set_kwargs(func_kwargs, *args, **kwargs): - new_args=[] - kwargs, name, update_kwargs = get_kwargs(kwargs, ['name', None, 'update_kwargs', None]) - args, kwargs, lst, dct = __get_default_listdict_args__(args, kwargs, 'set') - if isinstance(lst, list): dct.update({lst[i*2]: lst[i*2+1] for i in range(0, len(lst)/2)}); lst = [] - for arg in args: new_args += item_to_lst(arg, False) - dct.update({key: None for key in item_to_list(lst, chrs=',') + new_args }) - dct.update(kwargs) - return DictCaseInsensitive(process_kwargs(func_kwargs, set_dict=dct, name=name, update_kwargs=update_kwargs)) + new_args = [] + kwargs, name, update_kwargs = get_kwargs(kwargs, ['name', None, 'update_kwargs', None]) + args, kwargs, lst, dct = __get_default_listdict_args__(args, kwargs, 'set') + if isinstance(lst, list): dct.update({lst[i * 2]: lst[i * 2 + 1] for i in range(0, len(lst) / 2)}); lst = [] + for arg in args: new_args += item_to_lst(arg, False) + dct.update({key: None for key in item_to_list(lst, chrs=',') + new_args}) + dct.update(kwargs) + return DictCaseInsensitive(process_kwargs(func_kwargs, set_dict=dct, name=name, update_kwargs=update_kwargs)) + def obj2log_simple(content): - if not isinstance(content, str) and not isinstance(content, unicode): - content = str(content) - return content + if not isinstance(content, str) and not isinstance(content, unicode): + content = str(content) + return content + def convert_filename_to_local_link(filename): - return 'file:///' + filename.replace("\\", "//") + return 'file:///' + filename.replace("\\", "//") + class Logger(object): - base_path = None - caller_info=None - default_filename=None - def wrap_filename(self, filename=None): - if filename is None: filename = self.default_filename - if self.base_path is not None: - filename = os.path.join(self.base_path, filename if filename else '') - return filename + base_path = None + caller_info = None + default_filename = None - def dump(self, obj, title='', filename=None, *args, **kwargs): - filename = self.wrap_filename(filename) - log_dump(obj=obj, title=title, filename=filename, *args, **kwargs) + def wrap_filename(self, filename=None): + if filename is None: filename = self.default_filename + if self.base_path is not None: + filename = os.path.join(self.base_path, filename if filename else '') + return filename - def blank(self, filename=None, *args, **kwargs): - filename = self.wrap_filename(filename) - log_blank(filename=filename, *args, **kwargs) + def dump(self, obj, title='', filename=None, *args, **kwargs): + filename = self.wrap_filename(filename) + log_dump(obj=obj, title=title, filename=filename, *args, **kwargs) - def banner(self, title, filename=None, *args, **kwargs): - filename = self.wrap_filename(filename) - log_banner(title=title, filename=filename, *args, **kwargs) + def blank(self, filename=None, *args, **kwargs): + filename = self.wrap_filename(filename) + log_blank(filename=filename, *args, **kwargs) - def go(self, content=None, filename=None, wrap_filename=True, *args, **kwargs): - if wrap_filename: filename = self.wrap_filename(filename) - log(content=content, filename=filename, *args, **kwargs) + def banner(self, title, filename=None, *args, **kwargs): + filename = self.wrap_filename(filename) + log_banner(title=title, filename=filename, *args, **kwargs) - def plain(self, content=None, filename=None, *args, **kwargs): - filename=self.wrap_filename(filename) - log_plain(content=content, filename=filename, *args, **kwargs) + def go(self, content=None, filename=None, wrap_filename=True, *args, **kwargs): + if wrap_filename: filename = self.wrap_filename(filename) + log(content=content, filename=filename, *args, **kwargs) - log = do = add = go + def plain(self, content=None, filename=None, *args, **kwargs): + filename = self.wrap_filename(filename) + log_plain(content=content, filename=filename, *args, **kwargs) - def default(self, *args, **kwargs): - self.log(wrap_filename=False, *args, **kwargs) + log = do = add = go - def __init__(self, base_path=None, default_filename=None, rm_path=False): - self.default_filename = default_filename - if base_path: - self.base_path = base_path - else: - self.caller_info = caller_name() - if self.caller_info: - self.base_path = create_log_filename(self.caller_info.Base) - if rm_path: - rm_log_path(self.base_path) + def default(self, *args, **kwargs): + self.log(wrap_filename=False, *args, **kwargs) + def __init__(self, base_path=None, default_filename=None, rm_path=False): + self.default_filename = default_filename + if base_path: + self.base_path = base_path + else: + self.caller_info = caller_name() + if self.caller_info: + self.base_path = create_log_filename(self.caller_info.Base) + if rm_path: + rm_log_path(self.base_path) def log_blank(filename=None, *args, **kwargs): - log(timestamp=False, content=None, filename=filename, *args, **kwargs) + log(timestamp=False, content=None, filename=filename, *args, **kwargs) def log_plain(*args, **kwargs): - log(timestamp=False, *args, **kwargs) + log(timestamp=False, *args, **kwargs) + def rm_log_path(filename='*', subfolders_only=False, retry_errors=0): - path = os.path.dirname(os.path.abspath(get_log_full_path(filename))) - if path is FOLDERS.LOGS or FOLDERS.LOGS not in path: return - rm_log_path.errors = [] - def del_subfolder(arg=None,dirname=None,filenames=None, is_subfolder=True): - def rmtree_error(f, p, e): - rm_log_path.errors += [p] - if is_subfolder and dirname is path: return - shutil.rmtree(dirname, onerror=rmtree_error) - if not subfolders_only: del_subfolder(dirname=path, is_subfolder=False) - else: os.path.walk(path, del_subfolder, None) - if rm_log_path.errors: - if retry_errors > 5: - print "Unable to delete log path" - log("Unable to delete log path as requested", filename) - return - time.sleep(1) - rm_log_path(filename, subfolders_only, retry_errors + 1) - -def log_banner(title, filename=None, length=ANKNOTES.FORMATTING.BANNER_MINIMUM, append_newline=True, timestamp=False, chr='-', center=True, clear=True, *args, **kwargs): - if length is 0: length = ANKNOTES.FORMATTING.LINE_LENGTH+1 - if center: title = title.center(length-TIMESTAMP_PAD_LENGTH if timestamp else 0) - log(chr * length, filename, clear=clear, timestamp=False, *args, **kwargs) - log(title, filename, timestamp=timestamp, *args, **kwargs) - log(chr * length, filename, timestamp=False, *args, **kwargs) - if append_newline: log_blank(filename, *args, **kwargs) + path = os.path.dirname(os.path.abspath(get_log_full_path(filename))) + if path is FOLDERS.LOGS or FOLDERS.LOGS not in path: return + rm_log_path.errors = [] + + def del_subfolder(arg=None, dirname=None, filenames=None, is_subfolder=True): + def rmtree_error(f, p, e): + rm_log_path.errors += [p] + + if is_subfolder and dirname is path: return + shutil.rmtree(dirname, onerror=rmtree_error) + + if not subfolders_only: + del_subfolder(dirname=path, is_subfolder=False) + else: + os.path.walk(path, del_subfolder, None) + if rm_log_path.errors: + if retry_errors > 5: + print + "Unable to delete log path" + log("Unable to delete log path as requested", filename) + return + time.sleep(1) + rm_log_path(filename, subfolders_only, retry_errors + 1) + + +def log_banner(title, filename=None, length=ANKNOTES.FORMATTING.BANNER_MINIMUM, append_newline=True, timestamp=False, + chr='-', center=True, clear=True, *args, **kwargs): + if length is 0: length = ANKNOTES.FORMATTING.LINE_LENGTH + 1 + if center: title = title.center(length - TIMESTAMP_PAD_LENGTH if timestamp else 0) + log(chr * length, filename, clear=clear, timestamp=False, *args, **kwargs) + log(title, filename, timestamp=timestamp, *args, **kwargs) + log(chr * length, filename, timestamp=False, *args, **kwargs) + if append_newline: log_blank(filename, *args, **kwargs) + _log_filename_history = [] + + def set_current_log(fn): - global _log_filename_history - _log_filename_history.append(fn) + global _log_filename_history + _log_filename_history.append(fn) + def end_current_log(fn=None): - global _log_filename_history - if fn: - _log_filename_history.remove(fn) - else: - _log_filename_history = _log_filename_history[:-1] + global _log_filename_history + if fn: + _log_filename_history.remove(fn) + else: + _log_filename_history = _log_filename_history[:-1] + def get_log_full_path(filename=None, extension='log', as_url_link=False, prefix='', **kwargs): - global _log_filename_history - log_base_name = FILES.LOGS.BASE_NAME - filename_suffix = '' - if filename and filename[0] == '*': - filename_suffix = '\\' + filename[1:] - log_base_name = '' - filename = None - if filename is None: - if FILES.LOGS.USE_CALLER_NAME: - caller = caller_name() - if caller: - filename = caller.Base.replace('.', '\\') - if filename is None: - filename = _log_filename_history[-1] if _log_filename_history else FILES.LOGS.ACTIVE - if not filename: - filename = log_base_name - if not filename: filename = FILES.LOGS.DEFAULT_NAME - else: - if filename[0] is '+': - filename = filename[1:] - filename = (log_base_name + '-' if log_base_name and log_base_name[-1] != '\\' else '') + filename - - filename += filename_suffix - filename += ('.' if filename and filename[-1] is not '.' else '') + extension - filename = re.sub(r'[^\w\-_\.\\]', '_', filename) - full_path = os.path.join(FOLDERS.LOGS, filename) - if prefix: - parent, fn = os.path.split(full_path) - if fn != '.' + extension: fn = '-' + fn - full_path = os.path.join(parent, prefix + fn) - if not os.path.exists(os.path.dirname(full_path)): - os.makedirs(os.path.dirname(full_path)) - if as_url_link: return convert_filename_to_local_link(full_path) - return full_path + global _log_filename_history + log_base_name = FILES.LOGS.BASE_NAME + filename_suffix = '' + if filename and filename[0] == '*': + filename_suffix = '\\' + filename[1:] + log_base_name = '' + filename = None + if filename is None: + if FILES.LOGS.USE_CALLER_NAME: + caller = caller_name() + if caller: + filename = caller.Base.replace('.', '\\') + if filename is None: + filename = _log_filename_history[-1] if _log_filename_history else FILES.LOGS.ACTIVE + if not filename: + filename = log_base_name + if not filename: filename = FILES.LOGS.DEFAULT_NAME + else: + if filename[0] is '+': + filename = filename[1:] + filename = (log_base_name + '-' if log_base_name and log_base_name[-1] != '\\' else '') + filename + + filename += filename_suffix + filename += ('.' if filename and filename[-1] is not '.' else '') + extension + filename = re.sub(r'[^\w\-_\.\\]', '_', filename) + full_path = os.path.join(FOLDERS.LOGS, filename) + if prefix: + parent, fn = os.path.split(full_path) + if fn != '.' + extension: fn = '-' + fn + full_path = os.path.join(parent, prefix + fn) + if not os.path.exists(os.path.dirname(full_path)): + os.makedirs(os.path.dirname(full_path)) + if as_url_link: return convert_filename_to_local_link(full_path) + return full_path + def encode_log_text(content, encode_text=True, **kwargs): - if not encode_text or not isinstance(content, str) and not isinstance(content, unicode): return content - try: return content.encode('utf-8') - except Exception: return content + if not encode_text or not isinstance(content, str) and not isinstance(content, unicode): return content + try: + return content.encode('utf-8') + except Exception: + return content + def parse_log_content(content, prefix='', **kwargs): - if content is None: return '', prefix - content = obj2log_simple(content) - if len(content) == 0: content = '{EMPTY STRING}' - if content[0] == "!": content = content[1:]; prefix = '\n' - return content, prefix - -def process_log_content(content, prefix='', timestamp=None, do_encode=True, **kwargs): - content = pad_lines_regex(content, timestamp=timestamp, **kwargs) - st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' - return prefix + ' ' + st + (encode_log_text(content, **kwargs) if do_encode else content), content - + if content is None: return '', prefix + content = obj2log_simple(content) + if len(content) == 0: content = '{EMPTY STRING}' + if content[0] == "!": content = content[1:]; prefix = '\n' + return content, prefix + + +def process_log_content(content, prefix='', timestamp=None, do_encode=True, **kwargs): + content = pad_lines_regex(content, timestamp=timestamp, **kwargs) + st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' + return prefix + ' ' + st + (encode_log_text(content, **kwargs) if do_encode else content), content + + def crosspost_log(content, filename=None, crosspost_to_default=False, crosspost=None, **kwargs): - if crosspost_to_default and filename: - summary = " ** %s%s: " % ('' if filename.upper() == 'ERROR' else 'CROSS-POST TO ', filename.upper()) + content - log(summary[:200], **kwargs) - if not crosspost: return - for fn in item_to_list(crosspost): log(content, fn, **kwargs) - + if crosspost_to_default and filename: + summary = " ** %s%s: " % ('' if filename.upper() == 'ERROR' else 'CROSS-POST TO ', filename.upper()) + content + log(summary[:200], **kwargs) + if not crosspost: return + for fn in item_to_list(crosspost): log(content, fn, **kwargs) + + def pad_lines_regex(content, timestamp=None, replace_newline=None, try_decode=True, **kwargs): - content = PadLines(content, **kwargs) - if not (timestamp and replace_newline is not False) and not replace_newline: return content - try: return re.sub(r'[\r\n]+', u'\n'+ANKNOTES.FORMATTING.TIMESTAMP_PAD, content) - except UnicodeDecodeError: - if not try_decode: raise - return re.sub(r'[\r\n]+', u'\n'+ANKNOTES.FORMATTING.TIMESTAMP_PAD, content.decode('utf-8')) - -def write_file_contents(content, full_path, clear=False, try_encode=True, do_print=False, print_timestamp=True, print_content=None, **kwargs): - if not os.path.exists(os.path.dirname(full_path)): full_path = get_log_full_path(full_path) - with open(full_path, 'w+' if clear else 'a+') as fileLog: - try: print>> fileLog, content - except UnicodeEncodeError: content = content.encode('utf-8'); print>> fileLog, content - if do_print: print content if print_timestamp or not print_content else print_content - + content = PadLines(content, **kwargs) + if not (timestamp and replace_newline is not False) and not replace_newline: return content + try: + return re.sub(r'[\r\n]+', u'\n' + ANKNOTES.FORMATTING.TIMESTAMP_PAD, content) + except UnicodeDecodeError: + if not try_decode: raise + return re.sub(r'[\r\n]+', u'\n' + ANKNOTES.FORMATTING.TIMESTAMP_PAD, content.decode('utf-8')) + + +def write_file_contents(content, full_path, clear=False, try_encode=True, do_print=False, print_timestamp=True, + print_content=None, **kwargs): + if not os.path.exists(os.path.dirname(full_path)): full_path = get_log_full_path(full_path) + with open(full_path, 'w+' if clear else 'a+') as fileLog: + try: + print >> fileLog, content + except UnicodeEncodeError: + content = content.encode('utf-8'); print >> fileLog, content + if do_print: print + content if print_timestamp or not print_content else print_content + + # @clockit def log(content=None, filename=None, **kwargs): - kwargs = set_kwargs(kwargs, 'line_padding, line_padding_plus, line_padding_header', timestamp=True) - content, prefix = parse_log_content(content, **kwargs) - crosspost_log(content, filename, **kwargs) - full_path = get_log_full_path(filename, **kwargs) - content, print_content = process_log_content(content, prefix, **kwargs) - write_file_contents(content, full_path, print_content=print_content, **kwargs) + kwargs = set_kwargs(kwargs, 'line_padding, line_padding_plus, line_padding_header', timestamp=True) + content, prefix = parse_log_content(content, **kwargs) + crosspost_log(content, filename, **kwargs) + full_path = get_log_full_path(filename, **kwargs) + content, print_content = process_log_content(content, prefix, **kwargs) + write_file_contents(content, full_path, print_content=print_content, **kwargs) + def log_sql(content, **kwargs): - log(content, 'sql', **kwargs) + log(content, 'sql', **kwargs) + def log_error(content, **kwargs): - kwargs = set_kwargs(kwargs, ['crosspost_to_default', True]) - log(content, 'error', **kwargs) + kwargs = set_kwargs(kwargs, ['crosspost_to_default', True]) + log(content, 'error', **kwargs) + def print_dump(obj): - content = pprint.pformat(obj, indent=4, width=ANKNOTES.FORMATTING.PPRINT_WIDTH) - content = content.replace(', ', ', \n ') - content = content.replace('\r', '\r ').replace('\n', - '\n ') - content = encode_log_text(content) - print content - return content + content = pprint.pformat(obj, indent=4, width=ANKNOTES.FORMATTING.PPRINT_WIDTH) + content = content.replace(', ', ', \n ') + content = content.replace('\r', '\r ').replace('\n', + '\n ') + content = encode_log_text(content) + print + content + return content + def log_dump(obj, title="Object", filename='', timestamp=True, extension='log', crosspost_to_default=True, **kwargs): - content = pprint.pformat(obj, indent=4, width=ANKNOTES.FORMATTING.PPRINT_WIDTH) - try: content = content.decode('utf-8', 'ignore') - except Exception: pass - content = content.replace("\\n", '\n').replace('\\r', '\r') - if filename and filename[0] is '+': - summary = " ** CROSS-POST TO %s: " % filename[1:] + content - log(summary[:200]) - # filename = 'dump' + ('-%s' % filename if filename else '') - full_path = get_log_full_path(filename, extension, prefix='dump') - st = '[%s]: ' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' - - if title[0] == '-': crosspost_to_default = False; title = title[1:] - prefix = " **** Dumping %s" % title - if crosspost_to_default: log(prefix) - - content = encode_log_text(content) - - try: - prefix += '\r\n' - content = prefix + content.replace(', ', ', \n ') - content = content.replace("': {", "': {\n ") - content = content.replace('\r', '\r' + ' ' * 30).replace('\n', '\n' + ' ' * 30) - except: - pass - - if not os.path.exists(os.path.dirname(full_path)): - os.makedirs(os.path.dirname(full_path)) - try_print(full_path, content, prefix, **kwargs) - -def try_print(full_path, content, prefix='', line_prefix=u'\n ', attempt=0, clear=False): - try: - print_content = line_prefix + (u' <%d>' % attempt if attempt > 0 else u'') + u' ' + st - if attempt is 0: print_content += content - elif attempt is 1: print_content += content.decode('utf-8') - elif attempt is 2: print_content += content.encode('utf-8') - elif attempt is 3: print_content = print_content.encode('utf-8') + content.encode('utf-8') - elif attempt is 4: print_content = print_content.decode('utf-8') + content.decode('utf-8') - elif attempt is 5: print_content += "Error printing content: " + str_safe(content) - elif attempt is 6: print_content += "Error printing content: " + content[:10] - elif attempt is 7: print_content += "Unable to print content." - with open(full_path, 'w+' if clear else 'a+') as fileLog: - print>> fileLog, print_content - except: - if attempt < 8: try_print(full_path, content, prefix=prefix, line_prefix=line_prefix, attempt=attempt+1, clear=clear) - + content = pprint.pformat(obj, indent=4, width=ANKNOTES.FORMATTING.PPRINT_WIDTH) + try: + content = content.decode('utf-8', 'ignore') + except Exception: + pass + content = content.replace("\\n", '\n').replace('\\r', '\r') + if filename and filename[0] is '+': + summary = " ** CROSS-POST TO %s: " % filename[1:] + content + log(summary[:200]) + # filename = 'dump' + ('-%s' % filename if filename else '') + full_path = get_log_full_path(filename, extension, prefix='dump') + st = '[%s]: ' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' + + if title[0] == '-': crosspost_to_default = False; title = title[1:] + prefix = " **** Dumping %s" % title + if crosspost_to_default: log(prefix) + + content = encode_log_text(content) + + try: + prefix += '\r\n' + content = prefix + content.replace(', ', ', \n ') + content = content.replace("': {", "': {\n ") + content = content.replace('\r', '\r' + ' ' * 30).replace('\n', '\n' + ' ' * 30) + except: + pass + + if not os.path.exists(os.path.dirname(full_path)): + os.makedirs(os.path.dirname(full_path)) + try_print(full_path, content, prefix, **kwargs) + + +def try_print(full_path, content, prefix='', line_prefix=u'\n ', attempt=0, clear=False): + try: + print_content = line_prefix + (u' <%d>' % attempt if attempt > 0 else u'') + u' ' + st + if attempt is 0: + print_content += content + elif attempt is 1: + print_content += content.decode('utf-8') + elif attempt is 2: + print_content += content.encode('utf-8') + elif attempt is 3: + print_content = print_content.encode('utf-8') + content.encode('utf-8') + elif attempt is 4: + print_content = print_content.decode('utf-8') + content.decode('utf-8') + elif attempt is 5: + print_content += "Error printing content: " + str_safe(content) + elif attempt is 6: + print_content += "Error printing content: " + content[:10] + elif attempt is 7: + print_content += "Unable to print content." + with open(full_path, 'w+' if clear else 'a+') as fileLog: + print >> fileLog, print_content + except: + if attempt < 8: try_print(full_path, content, prefix=prefix, line_prefix=line_prefix, attempt=attempt + 1, + clear=clear) + + def log_api(method, content='', **kwargs): - if content: content = ': ' + content - log(" API_CALL [%3d]: %10s%s" % (get_api_call_count(), method, content), 'api', **kwargs) + if content: content = ': ' + content + log(" API_CALL [%3d]: %10s%s" % (get_api_call_count(), method, content), 'api', **kwargs) def get_api_call_count(): - path = get_log_full_path('api') - if not os.path.exists(path): return 0 - api_log = file(path, 'r').read().splitlines() - count = 1 - for i in range(len(api_log), 0, -1): - call = api_log[i - 1] - if not "API_CALL" in call: - continue - ts = call.replace(':\t', ': ').split(': ')[0][2:-1] - td = datetime.now() - datetime.strptime(ts, ANKNOTES.DATE_FORMAT) - if td < timedelta(hours=1): - count += 1 - else: - return count - return count - -def caller_names(return_string=True, simplify=True): - return [c.Base if return_string else c for c in [__caller_name__(i,simplify) for i in range(0,20)] if c and c.Base] + path = get_log_full_path('api') + if not os.path.exists(path): return 0 + api_log = file(path, 'r').read().splitlines() + count = 1 + for i in range(len(api_log), 0, -1): + call = api_log[i - 1] + if not "API_CALL" in call: + continue + ts = call.replace(':\t', ': ').split(': ')[0][2:-1] + td = datetime.now() - datetime.strptime(ts, ANKNOTES.DATE_FORMAT) + if td < timedelta(hours=1): + count += 1 + else: + return count + return count + + +def caller_names(return_string=True, simplify=True): + return [c.Base if return_string else c for c in [__caller_name__(i, simplify) for i in range(0, 20)] if + c and c.Base] + class CallerInfo: - Class=[] - Module=[] - Outer=[] - Name="" - simplify=True - __keywords_exclude__=['pydevd', 'logging', 'stopwatch'] - __keywords_strip__=['__maxin__', 'anknotes', '<module>'] - __outer__ = [] - filtered=True - @property - def __trace__(self): - return self.Module + self.Outer + self.Class + [self.Name] - - @property - def Trace(self): - t= self._strip_(self.__trace__) - return t if not self.filtered or not [e for e in self.__keywords_exclude__ if e in t] else [] - - @property - def Base(self): - return '.'.join(self._strip_(self.Module + self.Class + [self.Name])) if self.Trace else '' - - @property - def Full(self): - return '.'.join(self.Trace) - - def _strip_(self, lst): - return [t for t in lst if t and t not in self.__keywords_strip__] - - def __init__(self, parentframe=None): - """ - - :rtype : CallerInfo - """ - if not parentframe: return - self.Class = parentframe.f_locals['self'].__class__.__name__.split('.') if 'self' in parentframe.f_locals else [] - module = inspect.getmodule(parentframe) - self.Module = module.__name__.split('.') if module else [] - self.Name = parentframe.f_code.co_name if parentframe.f_code.co_name is not '<module>' else '' - self.__outer__ = [[f[1], f[3]] for f in inspect.getouterframes(parentframe) if f] - self.__outer__.reverse() - self.Outer = [f[1] for f in self.__outer__ if f and f[1] and not [exclude for exclude in self.__keywords_exclude__ + [self.Name] if exclude in f[0] or exclude in f[1]]] - del parentframe + Class = [] + Module = [] + Outer = [] + Name = "" + simplify = True + __keywords_exclude__ = ['pydevd', 'logging', 'stopwatch'] + __keywords_strip__ = ['__maxin__', 'anknotes', '<module>'] + __outer__ = [] + filtered = True + + @property + def __trace__(self): + return self.Module + self.Outer + self.Class + [self.Name] + + @property + def Trace(self): + t = self._strip_(self.__trace__) + return t if not self.filtered or not [e for e in self.__keywords_exclude__ if e in t] else [] + + @property + def Base(self): + return '.'.join(self._strip_(self.Module + self.Class + [self.Name])) if self.Trace else '' + + @property + def Full(self): + return '.'.join(self.Trace) + + def _strip_(self, lst): + return [t for t in lst if t and t not in self.__keywords_strip__] + + def __init__(self, parentframe=None): + """ + + :rtype : CallerInfo + """ + if not parentframe: return + self.Class = parentframe.f_locals['self'].__class__.__name__.split( + '.') if 'self' in parentframe.f_locals else [] + module = inspect.getmodule(parentframe) + self.Module = module.__name__.split('.') if module else [] + self.Name = parentframe.f_code.co_name if parentframe.f_code.co_name is not '<module>' else '' + self.__outer__ = [[f[1], f[3]] for f in inspect.getouterframes(parentframe) if f] + self.__outer__.reverse() + self.Outer = [f[1] for f in self.__outer__ if + f and f[1] and not [exclude for exclude in self.__keywords_exclude__ + [self.Name] if + exclude in f[0] or exclude in f[1]]] + del parentframe + def create_log_filename(strr): - if strr is None: return "" - strr = strr.replace('.', '\\') - strr = re.sub(r"(^|\\)([^\\]+)\\\2(\b.|\\.|$)", r"\1\2\\", strr) - strr = re.sub(r"^\\*(.+?)\\*$", r"\1", strr) - return strr + if strr is None: return "" + strr = strr.replace('.', '\\') + strr = re.sub(r"(^|\\)([^\\]+)\\\2(\b.|\\.|$)", r"\1\2\\", strr) + strr = re.sub(r"^\\*(.+?)\\*$", r"\1", strr) + return strr + + # @clockit def caller_name(skip=None, simplify=True, return_string=False, return_filename=False): - if skip is None: names = [__caller_name__(i,simplify) for i in range(0,20)] - else: names = [__caller_name__(skip, simplify=simplify)] - for c in [c for c in names if c and c.Base]: - return create_log_filename(c.Base) if return_filename else c.Base if return_string else c - return "" if return_filename or return_string else None + if skip is None: + names = [__caller_name__(i, simplify) for i in range(0, 20)] + else: + names = [__caller_name__(skip, simplify=simplify)] + for c in [c for c in names if c and c.Base]: + return create_log_filename(c.Base) if return_filename else c.Base if return_string else c + return "" if return_filename or return_string else None + def __caller_name__(skip=0, simplify=True): - """Get a name of a caller in the format module.class.method - - `skip` specifies how many levels of stack to skip while getting caller - name. skip=1 means "who calls me", skip=2 "who calls my caller" etc. - - An empty string is returned if skipped levels exceed stack height - :rtype : CallerInfo - """ - stack = inspect.stack() - start = 0 + skip - if len(stack) < start + 1: - return None - parentframe = stack[start][0] - c_info = CallerInfo(parentframe) - del parentframe - return c_info - -# log('completed %s' % __name__, 'import') \ No newline at end of file + """Get a name of a caller in the format module.class.method + + `skip` specifies how many levels of stack to skip while getting caller + name. skip=1 means "who calls me", skip=2 "who calls my caller" etc. + + An empty string is returned if skipped levels exceed stack height + :rtype : CallerInfo + """ + stack = inspect.stack() + start = 0 + skip + if len(stack) < start + 1: + return None + parentframe = stack[start][0] + c_info = CallerInfo(parentframe) + del parentframe + return c_info + + # log('completed %s' % __name__, 'import') \ No newline at end of file diff --git a/anknotes/menu.py b/anknotes/menu.py index acc8b8e..a9d8e8a 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -2,10 +2,11 @@ # Python Imports from subprocess import * from datetime import datetime + try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite # Anknotes Shared Imports from anknotes.shared import * @@ -26,294 +27,317 @@ # noinspection PyTypeChecker def anknotes_setup_menu(): - menu_items = [ - [u"&Anknotes", - [ - ["&Import from Evernote", import_from_evernote], - ["&Enable Auto Import On Profile Load", {'action': anknotes_menu_auto_import_changed, 'checkable': True}], - ["Note &Validation", - [ - ["Validate &And Upload Pending Notes", validate_pending_notes], - ["SEPARATOR", None], - ["&Validate Pending Notes", lambda: validate_pending_notes(True, False)], - ["&Upload Validated Notes", upload_validated_notes] - ] - ], - ["Process &See Also Footer Links [Power Users Only!]", - [ - ["Complete All &Steps", see_also], - ["SEPARATOR", None], - ["Step &1: Process Anki Notes Without See Also Field", lambda: see_also(1)], - ["Step &2: Extract Links from TOC", lambda: see_also(2)], - ["SEPARATOR", None], - ["Step &3: Create Auto TOC Evernote Notes", lambda: see_also(3)], - ["Step &4: Validate and Upload Auto TOC Notes", lambda: see_also(4)], - ["Step &5: Rebuild TOC/Outline Link Database", lambda: see_also(5)], - ["SEPARATOR", None], - ["Step &6: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(6)], - ["Step &7: Update See Also Footer In Evernote Notes", lambda: see_also(7)], - ["Step &8: Validate and Upload Modified Evernote Notes", lambda: see_also(8)], - ["SEPARATOR", None], - ["Step &9: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(9)] - ] - ], - ["&Maintenance Tasks", - [ - ["Find &Deleted Notes", find_deleted_notes], - ["Res&ync with Local DB", resync_with_local_db], - ["Update Evernote &Ancillary Data", update_ancillary_data] - ] - ] - - ] - ] - ] - add_menu_items(menu_items) + menu_items = [ + [u"&Anknotes", + [ + ["&Import from Evernote", import_from_evernote], + ["&Enable Auto Import On Profile Load", {'action': anknotes_menu_auto_import_changed, 'checkable': True}], + ["Note &Validation", + [ + ["Validate &And Upload Pending Notes", validate_pending_notes], + ["SEPARATOR", None], + ["&Validate Pending Notes", lambda: validate_pending_notes(True, False)], + ["&Upload Validated Notes", upload_validated_notes] + ] + ], + ["Process &See Also Footer Links [Power Users Only!]", + [ + ["Complete All &Steps", see_also], + ["SEPARATOR", None], + ["Step &1: Process Anki Notes Without See Also Field", lambda: see_also(1)], + ["Step &2: Extract Links from TOC", lambda: see_also(2)], + ["SEPARATOR", None], + ["Step &3: Create Auto TOC Evernote Notes", lambda: see_also(3)], + ["Step &4: Validate and Upload Auto TOC Notes", lambda: see_also(4)], + ["Step &5: Rebuild TOC/Outline Link Database", lambda: see_also(5)], + ["SEPARATOR", None], + ["Step &6: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(6)], + ["Step &7: Update See Also Footer In Evernote Notes", lambda: see_also(7)], + ["Step &8: Validate and Upload Modified Evernote Notes", lambda: see_also(8)], + ["SEPARATOR", None], + ["Step &9: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(9)] + ] + ], + ["&Maintenance Tasks", + [ + ["Find &Deleted Notes", find_deleted_notes], + ["Res&ync with Local DB", resync_with_local_db], + ["Update Evernote &Ancillary Data", update_ancillary_data] + ] + ] + + ] + ] + ] + add_menu_items(menu_items) + def auto_reload_wrapper(function): return lambda: auto_reload_modules(function) + def auto_reload_modules(function): - if ANKNOTES.DEVELOPER_MODE.ENABLED and ANKNOTES.DEVELOPER_MODE.AUTO_RELOAD_MODULES: - anknotes.shared = reload(anknotes.shared) - if not anknotes.Controller: importlib.import_module('anknotes.Controller') - reload(anknotes.Controller) - function() + if ANKNOTES.DEVELOPER_MODE.ENABLED and ANKNOTES.DEVELOPER_MODE.AUTO_RELOAD_MODULES: + anknotes.shared = reload(anknotes.shared) + if not anknotes.Controller: importlib.import_module('anknotes.Controller') + reload(anknotes.Controller) + function() + def add_menu_items(menu_items, parent=None): - if not parent: parent = mw.form.menubar - for title, action in menu_items: - if title == "SEPARATOR": - parent.addSeparator() - elif isinstance(action, list): - menu = QMenu(_(title), parent) - parent.insertMenu(mw.form.menuTools.menuAction(), menu) - add_menu_items(action, menu) - else: - checkable = False - if isinstance(action, dict): - options = action - action = options['action'] - if 'checkable' in options: - checkable = options['checkable'] - # if ANKNOTES.DEVELOPER_MODE.ENABLED and ANKNOTES.DEVELOPER_MODE.AUTO_RELOAD_MODULES: - action = auto_reload_wrapper(action) - menu_action = QAction(_(title), mw, checkable=checkable) - parent.addAction(menu_action) - parent.connect(menu_action, SIGNAL("triggered()"), action) - if checkable: - anknotes_checkable_menu_items[title] = menu_action + if not parent: parent = mw.form.menubar + for title, action in menu_items: + if title == "SEPARATOR": + parent.addSeparator() + elif isinstance(action, list): + menu = QMenu(_(title), parent) + parent.insertMenu(mw.form.menuTools.menuAction(), menu) + add_menu_items(action, menu) + else: + checkable = False + if isinstance(action, dict): + options = action + action = options['action'] + if 'checkable' in options: + checkable = options['checkable'] + # if ANKNOTES.DEVELOPER_MODE.ENABLED and ANKNOTES.DEVELOPER_MODE.AUTO_RELOAD_MODULES: + action = auto_reload_wrapper(action) + menu_action = QAction(_(title), mw, checkable=checkable) + parent.addAction(menu_action) + parent.connect(menu_action, SIGNAL("triggered()"), action) + if checkable: + anknotes_checkable_menu_items[title] = menu_action def anknotes_menu_auto_import_changed(): - title = "&Enable Auto Import On Profile Load" - doAutoImport = anknotes_checkable_menu_items[title].isChecked() - mw.col.conf[ - SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', '')] = doAutoImport - mw.col.setMod() - mw.col.save() - # import_timer_toggle() + title = "&Enable Auto Import On Profile Load" + doAutoImport = anknotes_checkable_menu_items[title].isChecked() + mw.col.conf[ + SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', '')] = doAutoImport + mw.col.setMod() + mw.col.save() + + +# import_timer_toggle() def anknotes_load_menu_settings(): - global anknotes_checkable_menu_items - for title, menu_action in anknotes_checkable_menu_items.items(): - menu_action.setChecked(mw.col.conf.get( - SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False)) + global anknotes_checkable_menu_items + for title, menu_action in anknotes_checkable_menu_items.items(): + menu_action.setChecked(mw.col.conf.get( + SETTINGS.ANKNOTES_CHECKABLE_MENU_ITEMS_PREFIX + '_' + title.replace(' ', '_').replace('&', ''), False)) def import_from_evernote_manual_metadata(guids=None): - if not guids: - guids = find_evernote_guids(file(FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES, 'r').read()) - log("Manually downloading %d Notes" % len(guids)) - controller = anknotes.Controller.Controller() - controller.forceAutoPage = True - controller.currentPage = 1 - controller.ManualGUIDs = guids - controller.proceed() + if not guids: + guids = find_evernote_guids(file(FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES, 'r').read()) + log("Manually downloading %d Notes" % len(guids)) + controller = anknotes.Controller.Controller() + controller.forceAutoPage = True + controller.currentPage = 1 + controller.ManualGUIDs = guids + controller.proceed() def import_from_evernote(auto_page_callback=None): - controller = anknotes.Controller.Controller() - controller.auto_page_callback = auto_page_callback - if auto_page_callback: - controller.forceAutoPage = True - controller.currentPage = 1 - else: - controller.forceAutoPage = False - controller.currentPage = mw.col.conf.get(SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE, 1) - controller.proceed() + controller = anknotes.Controller.Controller() + controller.auto_page_callback = auto_page_callback + if auto_page_callback: + controller.forceAutoPage = True + controller.currentPage = 1 + else: + controller.forceAutoPage = False + controller.currentPage = mw.col.conf.get(SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE, 1) + controller.proceed() def upload_validated_notes(automated=False): - controller = anknotes.Controller.Controller() - controller.upload_validated_notes(automated) + controller = anknotes.Controller.Controller() + controller.upload_validated_notes(automated) def find_deleted_notes(automated=False): - if not automated: - showInfo("""In order for this to work, you must create a 'Table of Contents' Note using the Evernote desktop application. Include all notes that you want to sync with Anki. + if not automated: + showInfo("""In order for this to work, you must create a 'Table of Contents' Note using the Evernote desktop application. Include all notes that you want to sync with Anki. Export this note to the following path: <b>%s</b> Press Okay to save and close your Anki collection, open the command-line deleted notes detection tool, and then re-open your Anki collection. -Once the command line tool is done running, you will get a summary of the results, and will be prompted to delete Anki Orphan Notes or download Missing Evernote Notes""".replace('\n', '\n<br />') % FILES.USER.TABLE_OF_CONTENTS_ENEX, - richText=True) - - # mw.col.save() - # if not automated: - # mw.unloadCollection() - # else: - # mw.col.close() - # handle = Popen(['python',FILES.SCRIPTS.FIND_DELETED_NOTES], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) - # stdoutdata, stderrdata = handle.communicate() - # err = ("ERROR: {%s}\n\n" % stderrdata) if stderrdata else '' - # stdoutdata = re.sub(' +', ' ', stdoutdata) - from anknotes import find_deleted_notes - returnedData = find_deleted_notes.do_find_deleted_notes() - if returnedData is False: - showInfo("An error occurred while executing the script. Please ensure you created the TOC note and saved it as instructed in the previous dialog.") - return - lines = returnedData['Summary'] - info = tableify_lines(lines, '#|Type|Info') - # info = '<table><tr class=tr0><td class=t1>#</td><td class=t2>Type</td><td class=t3></td></tr>%s</table>' % '\n'.join(lines) - # info = info.replace('\n', '\n<BR>').replace(' ', '    ') - anknotes_dels = returnedData['AnknotesOrphans'] - anknotes_dels_count = len(anknotes_dels) - anki_dels = returnedData['AnkiOrphans'] - anki_dels_count = len(anki_dels) - missing_evernote_notes = returnedData['MissingEvernoteNotes'] - missing_evernote_notes_count = len(missing_evernote_notes) - showInfo(info, richText=True, minWidth=600) - db_changed = False - if anknotes_dels_count > 0: - code = \ - getText("Please enter code 'ANKNOTES_DEL_%d' to delete your orphan Anknotes DB note(s)" % anknotes_dels_count)[ - 0] - if code == 'ANKNOTES_DEL_%d' % anknotes_dels_count: - ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, [[x] for x in anknotes_dels]) - delete_anki_notes_and_cards_by_guid(anknotes_dels) - db_changed = True - show_tooltip("Deleted all %d Orphan Anknotes DB Notes" % anknotes_dels_count, 5000, 3000) - if anki_dels_count > 0: - code = getText("Please enter code 'ANKI_DEL_%d' to delete your orphan Anki note(s)" % anki_dels_count)[0] - if code == 'ANKI_DEL_%d' % anki_dels_count: - delete_anki_notes_and_cards_by_guid(anki_dels) - db_changed = True - show_tooltip("Deleted all %d Orphan Anki Notes" % anki_dels_count, 5000, 6000) - if db_changed: - ankDB().commit() - if missing_evernote_notes_count > 0: - if showInfo("Would you like to import %d missing Evernote Notes?<BR><BR><a href='%s'>Click to view results</a>" % (missing_evernote_notes_count, convert_filename_to_local_link(get_log_full_path(FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES))), cancelButton=True, richText=True): - import_from_evernote_manual_metadata(missing_evernote_notes) +Once the command line tool is done running, you will get a summary of the results, and will be prompted to delete Anki Orphan Notes or download Missing Evernote Notes""".replace( + '\n', '\n<br />') % FILES.USER.TABLE_OF_CONTENTS_ENEX, + richText=True) + + # mw.col.save() + # if not automated: + # mw.unloadCollection() + # else: + # mw.col.close() + # handle = Popen(['python',FILES.SCRIPTS.FIND_DELETED_NOTES], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) + # stdoutdata, stderrdata = handle.communicate() + # err = ("ERROR: {%s}\n\n" % stderrdata) if stderrdata else '' + # stdoutdata = re.sub(' +', ' ', stdoutdata) + from anknotes import find_deleted_notes + + returnedData = find_deleted_notes.do_find_deleted_notes() + if returnedData is False: + showInfo( + "An error occurred while executing the script. Please ensure you created the TOC note and saved it as instructed in the previous dialog.") + return + lines = returnedData['Summary'] + info = tableify_lines(lines, '#|Type|Info') + # info = '<table><tr class=tr0><td class=t1>#</td><td class=t2>Type</td><td class=t3></td></tr>%s</table>' % '\n'.join(lines) + # info = info.replace('\n', '\n<BR>').replace(' ', '    ') + anknotes_dels = returnedData['AnknotesOrphans'] + anknotes_dels_count = len(anknotes_dels) + anki_dels = returnedData['AnkiOrphans'] + anki_dels_count = len(anki_dels) + missing_evernote_notes = returnedData['MissingEvernoteNotes'] + missing_evernote_notes_count = len(missing_evernote_notes) + showInfo(info, richText=True, minWidth=600) + db_changed = False + if anknotes_dels_count > 0: + code = \ + getText( + "Please enter code 'ANKNOTES_DEL_%d' to delete your orphan Anknotes DB note(s)" % anknotes_dels_count)[ + 0] + if code == 'ANKNOTES_DEL_%d' % anknotes_dels_count: + ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, [[x] for x in anknotes_dels]) + delete_anki_notes_and_cards_by_guid(anknotes_dels) + db_changed = True + show_tooltip("Deleted all %d Orphan Anknotes DB Notes" % anknotes_dels_count, 5000, 3000) + if anki_dels_count > 0: + code = getText("Please enter code 'ANKI_DEL_%d' to delete your orphan Anki note(s)" % anki_dels_count)[0] + if code == 'ANKI_DEL_%d' % anki_dels_count: + delete_anki_notes_and_cards_by_guid(anki_dels) + db_changed = True + show_tooltip("Deleted all %d Orphan Anki Notes" % anki_dels_count, 5000, 6000) + if db_changed: + ankDB().commit() + if missing_evernote_notes_count > 0: + if showInfo( + "Would you like to import %d missing Evernote Notes?<BR><BR><a href='%s'>Click to view results</a>" % ( + missing_evernote_notes_count, + convert_filename_to_local_link(get_log_full_path(FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES))), + cancelButton=True, richText=True): + import_from_evernote_manual_metadata(missing_evernote_notes) def validate_pending_notes(showAlerts=True, uploadAfterValidation=True, callback=None): - mw.unloadCollection() - if showAlerts: - showInfo("""Press Okay to save and close your Anki collection, open the command-line note validation tool, and then re-open your Anki collection.%s + mw.unloadCollection() + if showAlerts: + showInfo("""Press Okay to save and close your Anki collection, open the command-line note validation tool, and then re-open your Anki collection.%s Anki will be unresponsive until the validation tool completes. This will take at least 45 seconds. The tool's output will be displayed upon completion. """ - % ( - ' You will be given the option of uploading successfully validated notes once your Anki collection is reopened.' if uploadAfterValidation else '')) - handle = Popen(['python', FILES.SCRIPTS.VALIDATION], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) - stdoutdata, stderrdata = handle.communicate() - stdoutdata = re.sub(' +', ' ', stdoutdata) - info = ("ERROR: {%s}<HR>" % stderrdata) if stderrdata else '' - allowUpload = True - if showAlerts: - tds = [[str(count), '<a href="%s">VIEW %s VALIDATIONS LOG</a>' % (fn, key.upper())] for key, fn, count in [ - [key, get_log_full_path('MakeNoteQueue\\' + key, as_url_link=True), int(re.search(r'CHECKING +(\d{1,3}) +' + key.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1))] - for key in ['Pending', 'Successful', 'Failed']] if count > 0] - if not tds: - show_tooltip("No notes found in the validation queue.") - allowUpload = False - else: - info += tableify_lines(tds, '#|Results') - successful = int(re.search(r'CHECKING +(\d{1,3}) +' + 'Successful'.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1)) - allowUpload = (uploadAfterValidation and successful > 0) - allowUpload = allowUpload & showInfo("Completed: %s<BR>%s" % ( - 'Press Okay to begin uploading %d successfully validated note(s) to the Evernote Servers' % successful if (uploadAfterValidation and successful > 0) else '', - info), cancelButton=(successful > 0), richText=True) - - - # mw.col.reopen() - # mw.col.load() - - if callback is None and allowUpload: - callback = upload_validated_notes - external_tool_callback_timer(callback) + % ( + ' You will be given the option of uploading successfully validated notes once your Anki collection is reopened.' if uploadAfterValidation else '')) + handle = Popen(['python', FILES.SCRIPTS.VALIDATION], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) + stdoutdata, stderrdata = handle.communicate() + stdoutdata = re.sub(' +', ' ', stdoutdata) + info = ("ERROR: {%s}<HR>" % stderrdata) if stderrdata else '' + allowUpload = True + if showAlerts: + tds = [[str(count), '<a href="%s">VIEW %s VALIDATIONS LOG</a>' % (fn, key.upper())] for key, fn, count in [ + [key, get_log_full_path('MakeNoteQueue\\' + key, as_url_link=True), + int(re.search(r'CHECKING +(\d{1,3}) +' + key.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group(1))] + for key in ['Pending', 'Successful', 'Failed']] if count > 0] + if not tds: + show_tooltip("No notes found in the validation queue.") + allowUpload = False + else: + info += tableify_lines(tds, '#|Results') + successful = int( + re.search(r'CHECKING +(\d{1,3}) +' + 'Successful'.upper() + ' MAKE NOTE QUEUE ITEMS', stdoutdata).group( + 1)) + allowUpload = (uploadAfterValidation and successful > 0) + allowUpload = allowUpload & showInfo("Completed: %s<BR>%s" % ( + 'Press Okay to begin uploading %d successfully validated note(s) to the Evernote Servers' % successful if ( + uploadAfterValidation and successful > 0) else '', + info), cancelButton=(successful > 0), richText=True) + + + # mw.col.reopen() + # mw.col.load() + + if callback is None and allowUpload: + callback = upload_validated_notes + external_tool_callback_timer(callback) def reopen_collection(callback=None): - # mw.setupProfile() - mw.loadCollection() - ankDB(True) - if callback: callback() + # mw.setupProfile() + mw.loadCollection() + ankDB(True) + if callback: callback() def external_tool_callback_timer(callback=None): - mw.progress.timer(3000, lambda: reopen_collection(callback), False) + mw.progress.timer(3000, lambda: reopen_collection(callback), False) def see_also(steps=None, showAlerts=None, validationComplete=False): - controller = anknotes.Controller.Controller() - if not steps: steps = range(1, 10) - if isinstance(steps, int): steps = [steps] - multipleSteps = (len(steps) > 1) - if showAlerts is None: showAlerts = not multipleSteps - remaining_steps=steps - if 1 in steps: - # Should be unnecessary once See Also algorithms are finalized - log(" > See Also: Step 1: Process Un Added See Also Notes") - controller.process_unadded_see_also_notes() - if 2 in steps: - log(" > See Also: Step 2: Extract Links from TOC") - controller.anki.extract_links_from_toc() - if 3 in steps: - log(" > See Also: Step 3: Create Auto TOC Evernote Notes") - controller.create_auto_toc() - if 4 in steps: - if validationComplete: - log(" > See Also: Step 4B: Validate and Upload Auto TOC Notes: Upload Validated Notes") - upload_validated_notes(multipleSteps) - validationComplete = False - else: steps = [-4] - if 5 in steps: - log(" > See Also: Step 5: Rebuild TOC/Outline Link Database") - controller.anki.extract_links_from_toc() - if 6 in steps: - log(" > See Also: Step 6: Insert TOC/Outline Links Into Anki Notes' See Also Field") - controller.anki.insert_toc_into_see_also() - if 7 in steps: - log(" > See Also: Step 7: Update See Also Footer In Evernote Notes") - from anknotes import detect_see_also_changes - detect_see_also_changes.main() - if 8 in steps: - if validationComplete: - log(" > See Also: Step 8B: Validate and Upload Modified Evernote Notes: Upload Validated Notes") - upload_validated_notes(multipleSteps) - else: steps = [-8] - if 9 in steps: - log(" > See Also: Step 9: Insert TOC/Outline Contents Into Anki Notes") - controller.anki.insert_toc_and_outline_contents_into_notes() - - do_validation = steps[0]*-1 - if do_validation>0: - log(" > See Also: Step %dA: Validate and Upload %s Notes: Validate Notes" % (do_validation, {4: 'Auto TOC', 8: 'Modified Evernote'}[do_validation])) - remaining_steps = remaining_steps[remaining_steps.index(do_validation):] - validate_pending_notes(showAlerts, callback=lambda: see_also(remaining_steps, False, True)) + controller = anknotes.Controller.Controller() + if not steps: steps = range(1, 10) + if isinstance(steps, int): steps = [steps] + multipleSteps = (len(steps) > 1) + if showAlerts is None: showAlerts = not multipleSteps + remaining_steps = steps + if 1 in steps: + # Should be unnecessary once See Also algorithms are finalized + log(" > See Also: Step 1: Process Un Added See Also Notes") + controller.process_unadded_see_also_notes() + if 2 in steps: + log(" > See Also: Step 2: Extract Links from TOC") + controller.anki.extract_links_from_toc() + if 3 in steps: + log(" > See Also: Step 3: Create Auto TOC Evernote Notes") + controller.create_auto_toc() + if 4 in steps: + if validationComplete: + log(" > See Also: Step 4B: Validate and Upload Auto TOC Notes: Upload Validated Notes") + upload_validated_notes(multipleSteps) + validationComplete = False + else: + steps = [-4] + if 5 in steps: + log(" > See Also: Step 5: Rebuild TOC/Outline Link Database") + controller.anki.extract_links_from_toc() + if 6 in steps: + log(" > See Also: Step 6: Insert TOC/Outline Links Into Anki Notes' See Also Field") + controller.anki.insert_toc_into_see_also() + if 7 in steps: + log(" > See Also: Step 7: Update See Also Footer In Evernote Notes") + from anknotes import detect_see_also_changes + + detect_see_also_changes.main() + if 8 in steps: + if validationComplete: + log(" > See Also: Step 8B: Validate and Upload Modified Evernote Notes: Upload Validated Notes") + upload_validated_notes(multipleSteps) + else: + steps = [-8] + if 9 in steps: + log(" > See Also: Step 9: Insert TOC/Outline Contents Into Anki Notes") + controller.anki.insert_toc_and_outline_contents_into_notes() + + do_validation = steps[0] * -1 + if do_validation > 0: + log(" > See Also: Step %dA: Validate and Upload %s Notes: Validate Notes" % ( + do_validation, {4: 'Auto TOC', 8: 'Modified Evernote'}[do_validation])) + remaining_steps = remaining_steps[remaining_steps.index(do_validation):] + validate_pending_notes(showAlerts, callback=lambda: see_also(remaining_steps, False, True)) + def update_ancillary_data(): - controller = anknotes.Controller.Controller() - log("Ancillary data - loaded controller - " + str(controller.evernote) + " - " + str(controller.evernote.client), 'client') - controller.update_ancillary_data() + controller = anknotes.Controller.Controller() + log("Ancillary data - loaded controller - " + str(controller.evernote) + " - " + str(controller.evernote.client), + 'client') + controller.update_ancillary_data() def resync_with_local_db(): - controller = anknotes.Controller.Controller() - controller.resync_with_local_db() + controller = anknotes.Controller.Controller() + controller.resync_with_local_db() anknotes_checkable_menu_items = {} diff --git a/anknotes/settings.py b/anknotes/settings.py index 9fbadbf..f45d702 100644 --- a/anknotes/settings.py +++ b/anknotes/settings.py @@ -6,741 +6,743 @@ ### Anki Imports try: - import anki - import aqt - from aqt.preferences import Preferences - from aqt.utils import getText, openLink, getOnlyText - from aqt.qt import QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QGroupBox, SIGNAL, QCheckBox, \ - QComboBox, QSpacerItem, QSizePolicy, QWidget, QSpinBox, QFormLayout, QGridLayout, QFrame, QPalette, \ - QRect, QStackedLayout, QDateEdit, QDateTimeEdit, QTimeEdit, QDate, QDateTime, QTime, QPushButton, QIcon, \ - QMessageBox, QPixmap - from aqt import mw + import anki + import aqt + from aqt.preferences import Preferences + from aqt.utils import getText, openLink, getOnlyText + from aqt.qt import QLineEdit, QLabel, QVBoxLayout, QHBoxLayout, QGroupBox, SIGNAL, QCheckBox, \ + QComboBox, QSpacerItem, QSizePolicy, QWidget, QSpinBox, QFormLayout, QGridLayout, QFrame, QPalette, \ + QRect, QStackedLayout, QDateEdit, QDateTimeEdit, QTimeEdit, QDate, QDateTime, QTime, QPushButton, QIcon, \ + QMessageBox, QPixmap + from aqt import mw except: - pass + pass class EvernoteQueryLocationValueQSpinBox(QSpinBox): - __prefix = "" + __prefix = "" - def setPrefix(self, text): - self.__prefix = text + def setPrefix(self, text): + self.__prefix = text - def prefix(self): - return self.__prefix + def prefix(self): + return self.__prefix - def valueFromText(self, text): - if text == self.prefix(): - return 0 - return text[len(self.prefix()) + 1:] + def valueFromText(self, text): + if text == self.prefix(): + return 0 + return text[len(self.prefix()) + 1:] - def textFromValue(self, value): - if value == 0: - return self.prefix() - return self.prefix() + "-" + str(value) + def textFromValue(self, value): + if value == 0: + return self.prefix() + return self.prefix() + "-" + str(value) def setup_evernote(self): - global icoEvernoteWeb - global imgEvernoteWeb - global evernote_default_tag - global evernote_query_any - global evernote_query_use_tags - global evernote_query_tags - global evernote_query_use_excluded_tags - global evernote_query_excluded_tags - global evernote_query_use_notebook - global evernote_query_notebook - global evernote_query_use_note_title - global evernote_query_note_title - global evernote_query_use_search_terms - global evernote_query_search_terms - global evernote_query_use_last_updated - global evernote_query_last_updated_type - global evernote_query_last_updated_value_stacked_layout - global evernote_query_last_updated_value_relative_spinner - global evernote_query_last_updated_value_absolute_date - global evernote_query_last_updated_value_absolute_datetime - global evernote_query_last_updated_value_absolute_time - global default_anki_deck - global anki_deck_evernote_notebook_integration - global keep_evernote_tags - global delete_evernote_query_tags - global evernote_pagination_current_page_spinner - global evernote_pagination_auto_paging - - widget = QWidget() - layout = QVBoxLayout() - - - ########################## QUERY ########################## - group = QGroupBox("EVERNOTE SEARCH OPTIONS:") - group.setStyleSheet('QGroupBox{ font-size: 10px; font-weight: bold; color: rgb(105, 170, 53);}') - form = QFormLayout() - - form.addRow(gen_qt_hr()) - - # Evernote Query: Match Any Terms - evernote_query_any = QCheckBox(" Match Any Terms", self) - evernote_query_any.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.ANY, True)) - evernote_query_any.stateChanged.connect(update_evernote_query_any) - evernote_query_any.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - - button_show_generated_evernote_query = QPushButton(icoEvernoteWeb, "Show Full Query", self) - button_show_generated_evernote_query.setAutoDefault(False) - button_show_generated_evernote_query.connect(button_show_generated_evernote_query, - SIGNAL("clicked()"), - handle_show_generated_evernote_query) - - - # Add Form Row for Match Any Terms - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_any) - hbox.addWidget(button_show_generated_evernote_query) - form.addRow("<b>Search Query:</b>", hbox) - - # Evernote Query: Tags - evernote_query_tags = QLineEdit() - evernote_query_tags.setText( - mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE)) - evernote_query_tags.connect(evernote_query_tags, - SIGNAL("textEdited(QString)"), - update_evernote_query_tags) - - # Evernote Query: Use Tags - evernote_query_use_tags = QCheckBox(" ", self) - evernote_query_use_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_TAGS, True)) - evernote_query_use_tags.stateChanged.connect(update_evernote_query_use_tags) - - # Add Form Row for Tags - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_use_tags) - hbox.addWidget(evernote_query_tags) - form.addRow("Tags:", hbox) - - # Evernote Query: Excluded Tags - evernote_query_excluded_tags = QLineEdit() - evernote_query_excluded_tags.setText( - mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS, '')) - evernote_query_excluded_tags.connect(evernote_query_excluded_tags, - SIGNAL("textEdited(QString)"), - update_evernote_query_excluded_tags) - - # Evernote Query: Use Excluded Tags - evernote_query_use_excluded_tags = QCheckBox(" ", self) - evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True)) - evernote_query_use_excluded_tags.stateChanged.connect(update_evernote_query_use_excluded_tags) - - # Add Form Row for Excluded Tags - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_use_excluded_tags) - hbox.addWidget(evernote_query_excluded_tags) - form.addRow("Excluded Tags:", hbox) - - # Evernote Query: Search Terms - evernote_query_search_terms = QLineEdit() - evernote_query_search_terms.setText(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.SEARCH_TERMS, "")) - evernote_query_search_terms.connect(evernote_query_search_terms, - SIGNAL("textEdited(QString)"), - update_evernote_query_search_terms) - - # Evernote Query: Use Search Terms - evernote_query_use_search_terms = QCheckBox(" ", self) - evernote_query_use_search_terms.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS, False)) - evernote_query_use_search_terms.stateChanged.connect(update_evernote_query_use_search_terms) - - # Add Form Row for Search Terms - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_use_search_terms) - hbox.addWidget(evernote_query_search_terms) - form.addRow("Search Terms:", hbox) - - # Evernote Query: Notebook - evernote_query_notebook = QLineEdit() - evernote_query_notebook.setText( - mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTEBOOK, SETTINGS.EVERNOTE.QUERY.NOTEBOOK_DEFAULT_VALUE)) - evernote_query_notebook.connect(evernote_query_notebook, - SIGNAL("textEdited(QString)"), - update_evernote_query_notebook) - - # Evernote Query: Use Notebook - evernote_query_use_notebook = QCheckBox(" ", self) - evernote_query_use_notebook.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK, False)) - evernote_query_use_notebook.stateChanged.connect(update_evernote_query_use_notebook) - - # Add Form Row for Notebook - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_use_notebook) - hbox.addWidget(evernote_query_notebook) - form.addRow("Notebook:", hbox) - - # Evernote Query: Note Title - evernote_query_note_title = QLineEdit() - evernote_query_note_title.setText(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTE_TITLE, "")) - evernote_query_note_title.connect(evernote_query_note_title, - SIGNAL("textEdited(QString)"), - update_evernote_query_note_title) - - # Evernote Query: Use Note Title - evernote_query_use_note_title = QCheckBox(" ", self) - evernote_query_use_note_title.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE, False)) - evernote_query_use_note_title.stateChanged.connect(update_evernote_query_use_note_title) - - # Add Form Row for Note Title - hbox = QHBoxLayout() - hbox.addWidget(evernote_query_use_note_title) - hbox.addWidget(evernote_query_note_title) - form.addRow("Note Title:", hbox) - - # Evernote Query: Last Updated Type - evernote_query_last_updated_type = QComboBox() - evernote_query_last_updated_type.setStyleSheet(' QComboBox { color: rgb(45, 79, 201); font-weight: bold; } ') - evernote_query_last_updated_type.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - evernote_query_last_updated_type.addItems([u"Δ Day", u"Δ Week", u"Δ Month", u"Δ Year", "Date", "+ Time"]) - evernote_query_last_updated_type.setCurrentIndex(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE, - EvernoteQueryLocationType.RelativeDay)) - evernote_query_last_updated_type.activated.connect(update_evernote_query_last_updated_type) - - - # Evernote Query: Last Updated Type: Relative Date - evernote_query_last_updated_value_relative_spinner = EvernoteQueryLocationValueQSpinBox() - evernote_query_last_updated_value_relative_spinner.setVisible(False) - evernote_query_last_updated_value_relative_spinner.setStyleSheet( - " QSpinBox, EvernoteQueryLocationValueQSpinBox { font-weight: bold; color: rgb(173, 0, 0); } ") - evernote_query_last_updated_value_relative_spinner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - evernote_query_last_updated_value_relative_spinner.connect(evernote_query_last_updated_value_relative_spinner, - SIGNAL("valueChanged(int)"), - update_evernote_query_last_updated_value_relative_spinner) - - # Evernote Query: Last Updated Type: Absolute Date - evernote_query_last_updated_value_absolute_date = QDateEdit() - evernote_query_last_updated_value_absolute_date.setDisplayFormat('M/d/yy') - evernote_query_last_updated_value_absolute_date.setCalendarPopup(True) - evernote_query_last_updated_value_absolute_date.setVisible(False) - evernote_query_last_updated_value_absolute_date.setStyleSheet( - "QDateEdit { font-weight: bold; color: rgb(173, 0, 0); } ") - evernote_query_last_updated_value_absolute_date.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - evernote_query_last_updated_value_absolute_date.connect(evernote_query_last_updated_value_absolute_date, - SIGNAL("dateChanged(QDate)"), - update_evernote_query_last_updated_value_absolute_date) - - # Evernote Query: Last Updated Type: Absolute DateTime - evernote_query_last_updated_value_absolute_datetime = QDateTimeEdit() - evernote_query_last_updated_value_absolute_datetime.setDisplayFormat('M/d/yy h:mm AP') - evernote_query_last_updated_value_absolute_datetime.setCalendarPopup(True) - evernote_query_last_updated_value_absolute_datetime.setVisible(False) - evernote_query_last_updated_value_absolute_datetime.setStyleSheet( - "QDateTimeEdit { font-weight: bold; color: rgb(173, 0, 0); } ") - evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - evernote_query_last_updated_value_absolute_datetime.connect(evernote_query_last_updated_value_absolute_datetime, - SIGNAL("dateTimeChanged(QDateTime)"), - update_evernote_query_last_updated_value_absolute_datetime) - - - - # Evernote Query: Last Updated Type: Absolute Time - evernote_query_last_updated_value_absolute_time = QTimeEdit() - evernote_query_last_updated_value_absolute_time.setDisplayFormat('h:mm AP') - evernote_query_last_updated_value_absolute_time.setVisible(False) - evernote_query_last_updated_value_absolute_time.setStyleSheet( - "QTimeEdit { font-weight: bold; color: rgb(143, 0, 30); } ") - evernote_query_last_updated_value_absolute_time.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - evernote_query_last_updated_value_absolute_time.connect(evernote_query_last_updated_value_absolute_time, - SIGNAL("timeChanged(QTime)"), - update_evernote_query_last_updated_value_absolute_time) - - hbox_datetime = QHBoxLayout() - hbox_datetime.addWidget(evernote_query_last_updated_value_absolute_date) - hbox_datetime.addWidget(evernote_query_last_updated_value_absolute_time) - - # Evernote Query: Last Updated Type - evernote_query_last_updated_value_stacked_layout = QStackedLayout() - evernote_query_last_updated_value_stacked_layout.addWidget(evernote_query_last_updated_value_relative_spinner) - evernote_query_last_updated_value_stacked_layout.addItem(hbox_datetime) - - # Evernote Query: Use Last Updated - evernote_query_use_last_updated = QCheckBox(" ", self) - evernote_query_use_last_updated.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - evernote_query_use_last_updated.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_LAST_UPDATED, False)) - evernote_query_use_last_updated.stateChanged.connect(update_evernote_query_use_last_updated) - - # Add Form Row for Last Updated - hbox = QHBoxLayout() - label = QLabel("Last Updated: ") - label.setMinimumWidth(100) - hbox.addWidget(evernote_query_use_last_updated) - hbox.addWidget(evernote_query_last_updated_type) - hbox.addWidget(evernote_query_last_updated_value_relative_spinner) - hbox.addWidget(evernote_query_last_updated_value_absolute_date) - hbox.addWidget(evernote_query_last_updated_value_absolute_time) - form.addRow(label, hbox) - - # Add Horizontal Row Separator - form.addRow(gen_qt_hr()) - - ############################ PAGINATION ########################## - # Evernote Pagination: Current Page - evernote_pagination_current_page_spinner = QSpinBox() - evernote_pagination_current_page_spinner.setStyleSheet("QSpinBox { font-weight: bold; color: rgb(173, 0, 0); } ") - evernote_pagination_current_page_spinner.setPrefix("PAGE: ") - evernote_pagination_current_page_spinner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - evernote_pagination_current_page_spinner.setValue(mw.col.conf.get(SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE, 1)) - evernote_pagination_current_page_spinner.connect(evernote_pagination_current_page_spinner, - SIGNAL("valueChanged(int)"), - update_evernote_pagination_current_page_spinner) - - # Evernote Pagination: Auto Paging - evernote_pagination_auto_paging = QCheckBox(" Automate", self) - evernote_pagination_auto_paging.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - evernote_pagination_auto_paging.setFixedWidth(105) - evernote_pagination_auto_paging.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.AUTO_PAGING, True)) - evernote_pagination_auto_paging.stateChanged.connect(update_evernote_pagination_auto_paging) - - hbox = QHBoxLayout() - hbox.addWidget(evernote_pagination_auto_paging) - hbox.addWidget(evernote_pagination_current_page_spinner) - - # Add Form Row for Evernote Pagination - form.addRow("<b>Pagination:</b>", hbox) - - # Add Query Form to Group Box - group.setLayout(form) - - # Add Query Group Box to Main Layout - layout.addWidget(group) - - ########################## DECK ########################## - # label = QLabel("<span style='background-color: #bf0060;'><B><U>ANKI NOTE OPTIONS</U>:</B></span>") - group = QGroupBox("ANKI NOTE OPTIONS:") - group.setStyleSheet('QGroupBox{ font-size: 10px; font-weight: bold; color: rgb(105, 170, 53);}') - form = QFormLayout() - - # Add Horizontal Row Separator - form.addRow(gen_qt_hr()) - - # Default Anki Deck - default_anki_deck = QLineEdit() - default_anki_deck.setText(mw.col.conf.get(SETTINGS.ANKI.DECKS.BASE, SETTINGS.ANKI.DECKS.BASE_DEFAULT_VALUE)) - default_anki_deck.connect(default_anki_deck, SIGNAL("textEdited(QString)"), update_default_anki_deck) - - # Add Form Row for Default Anki Deck - hbox = QHBoxLayout() - hbox.insertSpacing(0, 33) - hbox.addWidget(default_anki_deck) - label_deck = QLabel("<b>Anki Deck:</b>") - label_deck.setMinimumWidth(100) - form.addRow(label_deck, hbox) - - # Evernote Notebook Integration - anki_deck_evernote_notebook_integration = QCheckBox(" Append Evernote Notebook", self) - anki_deck_evernote_notebook_integration.setChecked( - mw.col.conf.get(SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION, True)) - anki_deck_evernote_notebook_integration.stateChanged.connect(update_anki_deck_evernote_notebook_integration) - - # Change Visibility of Deck Options - update_anki_deck_visibilities() - - # Add Form Row for Evernote Notebook Integration - label_deck = QLabel("Evernote Notebook:") - label_deck.setMinimumWidth(100) - form.addRow("", anki_deck_evernote_notebook_integration) - - # Add Horizontal Row Separator - form.addRow(gen_qt_hr()) - - ############################ TAGS ########################## - # Keep Evernote Tags - keep_evernote_tags = QCheckBox(" Save To Anki Note", self) - keep_evernote_tags.setChecked( - mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE)) - keep_evernote_tags.stateChanged.connect(update_keep_evernote_tags) - - # Evernote Tags: Tags to Delete - evernote_tags_to_delete = QLineEdit() - evernote_tags_to_delete.setText(mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "")) - evernote_tags_to_delete.connect(evernote_tags_to_delete, - SIGNAL("textEdited(QString)"), - update_evernote_tags_to_delete) - - # Delete Tags To Import - delete_evernote_query_tags = QCheckBox(" Also Delete Search Tags", self) - delete_evernote_query_tags.setChecked(mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False)) - delete_evernote_query_tags.stateChanged.connect(update_delete_evernote_query_tags) - - # Add Form Row for Evernote Tag Options - label = QLabel("<b>Evernote Tags:</b>") - label.setMinimumWidth(100) - form.addRow(label, keep_evernote_tags) - hbox = QHBoxLayout() - hbox.insertSpacing(0, 33) - hbox.addWidget(evernote_tags_to_delete) - form.addRow("Tags to Delete:", hbox) - form.addRow(" ", delete_evernote_query_tags) - - # Add Horizontal Row Separator - form.addRow(gen_qt_hr()) - - ############################ NOTE UPDATING ########################## - # Note Update Method - update_existing_notes = QComboBox() - update_existing_notes.setStyleSheet( - ' QComboBox { color: #3b679e; font-weight: bold; } QComboBoxItem { color: #A40F2D; font-weight: bold; } ') - update_existing_notes.addItems(["Ignore Existing Notes", "Update In-Place", - "Delete and Re-Add"]) - update_existing_notes.setCurrentIndex(mw.col.conf.get(SETTINGS.ANKI.UPDATE_EXISTING_NOTES, - UpdateExistingNotes.UpdateNotesInPlace)) - update_existing_notes.activated.connect(update_update_existing_notes) - - # Add Form Row for Note Update Method - hbox = QHBoxLayout() - hbox.insertSpacing(0, 33) - hbox.addWidget(update_existing_notes) - form.addRow("<b>Note Updating:</b>", hbox) - - # Add Note Update Method Form to Group Box - group.setLayout(form) - - # Add Note Update Method Group Box to Main Layout - layout.addWidget(group) - - # Update Visibilities of Query Options - evernote_query_text_changed() - update_evernote_query_visibilities() - - - # Vertical Spacer - vertical_spacer = QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) - layout.addItem(vertical_spacer) - - # Parent Widget - widget.setLayout(layout) - - # New Tab - self.form.tabWidget.addTab(widget, "Anknotes") + global icoEvernoteWeb + global imgEvernoteWeb + global evernote_default_tag + global evernote_query_any + global evernote_query_use_tags + global evernote_query_tags + global evernote_query_use_excluded_tags + global evernote_query_excluded_tags + global evernote_query_use_notebook + global evernote_query_notebook + global evernote_query_use_note_title + global evernote_query_note_title + global evernote_query_use_search_terms + global evernote_query_search_terms + global evernote_query_use_last_updated + global evernote_query_last_updated_type + global evernote_query_last_updated_value_stacked_layout + global evernote_query_last_updated_value_relative_spinner + global evernote_query_last_updated_value_absolute_date + global evernote_query_last_updated_value_absolute_datetime + global evernote_query_last_updated_value_absolute_time + global default_anki_deck + global anki_deck_evernote_notebook_integration + global keep_evernote_tags + global delete_evernote_query_tags + global evernote_pagination_current_page_spinner + global evernote_pagination_auto_paging + + widget = QWidget() + layout = QVBoxLayout() + + + ########################## QUERY ########################## + group = QGroupBox("EVERNOTE SEARCH OPTIONS:") + group.setStyleSheet('QGroupBox{ font-size: 10px; font-weight: bold; color: rgb(105, 170, 53);}') + form = QFormLayout() + + form.addRow(gen_qt_hr()) + + # Evernote Query: Match Any Terms + evernote_query_any = QCheckBox(" Match Any Terms", self) + evernote_query_any.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.ANY, True)) + evernote_query_any.stateChanged.connect(update_evernote_query_any) + evernote_query_any.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + button_show_generated_evernote_query = QPushButton(icoEvernoteWeb, "Show Full Query", self) + button_show_generated_evernote_query.setAutoDefault(False) + button_show_generated_evernote_query.connect(button_show_generated_evernote_query, + SIGNAL("clicked()"), + handle_show_generated_evernote_query) + + + # Add Form Row for Match Any Terms + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_any) + hbox.addWidget(button_show_generated_evernote_query) + form.addRow("<b>Search Query:</b>", hbox) + + # Evernote Query: Tags + evernote_query_tags = QLineEdit() + evernote_query_tags.setText( + mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE)) + evernote_query_tags.connect(evernote_query_tags, + SIGNAL("textEdited(QString)"), + update_evernote_query_tags) + + # Evernote Query: Use Tags + evernote_query_use_tags = QCheckBox(" ", self) + evernote_query_use_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_TAGS, True)) + evernote_query_use_tags.stateChanged.connect(update_evernote_query_use_tags) + + # Add Form Row for Tags + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_tags) + hbox.addWidget(evernote_query_tags) + form.addRow("Tags:", hbox) + + # Evernote Query: Excluded Tags + evernote_query_excluded_tags = QLineEdit() + evernote_query_excluded_tags.setText( + mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS, '')) + evernote_query_excluded_tags.connect(evernote_query_excluded_tags, + SIGNAL("textEdited(QString)"), + update_evernote_query_excluded_tags) + + # Evernote Query: Use Excluded Tags + evernote_query_use_excluded_tags = QCheckBox(" ", self) + evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True)) + evernote_query_use_excluded_tags.stateChanged.connect(update_evernote_query_use_excluded_tags) + + # Add Form Row for Excluded Tags + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_excluded_tags) + hbox.addWidget(evernote_query_excluded_tags) + form.addRow("Excluded Tags:", hbox) + + # Evernote Query: Search Terms + evernote_query_search_terms = QLineEdit() + evernote_query_search_terms.setText(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.SEARCH_TERMS, "")) + evernote_query_search_terms.connect(evernote_query_search_terms, + SIGNAL("textEdited(QString)"), + update_evernote_query_search_terms) + + # Evernote Query: Use Search Terms + evernote_query_use_search_terms = QCheckBox(" ", self) + evernote_query_use_search_terms.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS, False)) + evernote_query_use_search_terms.stateChanged.connect(update_evernote_query_use_search_terms) + + # Add Form Row for Search Terms + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_search_terms) + hbox.addWidget(evernote_query_search_terms) + form.addRow("Search Terms:", hbox) + + # Evernote Query: Notebook + evernote_query_notebook = QLineEdit() + evernote_query_notebook.setText( + mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTEBOOK, SETTINGS.EVERNOTE.QUERY.NOTEBOOK_DEFAULT_VALUE)) + evernote_query_notebook.connect(evernote_query_notebook, + SIGNAL("textEdited(QString)"), + update_evernote_query_notebook) + + # Evernote Query: Use Notebook + evernote_query_use_notebook = QCheckBox(" ", self) + evernote_query_use_notebook.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK, False)) + evernote_query_use_notebook.stateChanged.connect(update_evernote_query_use_notebook) + + # Add Form Row for Notebook + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_notebook) + hbox.addWidget(evernote_query_notebook) + form.addRow("Notebook:", hbox) + + # Evernote Query: Note Title + evernote_query_note_title = QLineEdit() + evernote_query_note_title.setText(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTE_TITLE, "")) + evernote_query_note_title.connect(evernote_query_note_title, + SIGNAL("textEdited(QString)"), + update_evernote_query_note_title) + + # Evernote Query: Use Note Title + evernote_query_use_note_title = QCheckBox(" ", self) + evernote_query_use_note_title.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE, False)) + evernote_query_use_note_title.stateChanged.connect(update_evernote_query_use_note_title) + + # Add Form Row for Note Title + hbox = QHBoxLayout() + hbox.addWidget(evernote_query_use_note_title) + hbox.addWidget(evernote_query_note_title) + form.addRow("Note Title:", hbox) + + # Evernote Query: Last Updated Type + evernote_query_last_updated_type = QComboBox() + evernote_query_last_updated_type.setStyleSheet(' QComboBox { color: rgb(45, 79, 201); font-weight: bold; } ') + evernote_query_last_updated_type.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + evernote_query_last_updated_type.addItems([u"Δ Day", u"Δ Week", u"Δ Month", u"Δ Year", "Date", "+ Time"]) + evernote_query_last_updated_type.setCurrentIndex(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE, + EvernoteQueryLocationType.RelativeDay)) + evernote_query_last_updated_type.activated.connect(update_evernote_query_last_updated_type) + + + # Evernote Query: Last Updated Type: Relative Date + evernote_query_last_updated_value_relative_spinner = EvernoteQueryLocationValueQSpinBox() + evernote_query_last_updated_value_relative_spinner.setVisible(False) + evernote_query_last_updated_value_relative_spinner.setStyleSheet( + " QSpinBox, EvernoteQueryLocationValueQSpinBox { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_query_last_updated_value_relative_spinner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_relative_spinner.connect(evernote_query_last_updated_value_relative_spinner, + SIGNAL("valueChanged(int)"), + update_evernote_query_last_updated_value_relative_spinner) + + # Evernote Query: Last Updated Type: Absolute Date + evernote_query_last_updated_value_absolute_date = QDateEdit() + evernote_query_last_updated_value_absolute_date.setDisplayFormat('M/d/yy') + evernote_query_last_updated_value_absolute_date.setCalendarPopup(True) + evernote_query_last_updated_value_absolute_date.setVisible(False) + evernote_query_last_updated_value_absolute_date.setStyleSheet( + "QDateEdit { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_query_last_updated_value_absolute_date.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_absolute_date.connect(evernote_query_last_updated_value_absolute_date, + SIGNAL("dateChanged(QDate)"), + update_evernote_query_last_updated_value_absolute_date) + + # Evernote Query: Last Updated Type: Absolute DateTime + evernote_query_last_updated_value_absolute_datetime = QDateTimeEdit() + evernote_query_last_updated_value_absolute_datetime.setDisplayFormat('M/d/yy h:mm AP') + evernote_query_last_updated_value_absolute_datetime.setCalendarPopup(True) + evernote_query_last_updated_value_absolute_datetime.setVisible(False) + evernote_query_last_updated_value_absolute_datetime.setStyleSheet( + "QDateTimeEdit { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_absolute_datetime.connect(evernote_query_last_updated_value_absolute_datetime, + SIGNAL("dateTimeChanged(QDateTime)"), + update_evernote_query_last_updated_value_absolute_datetime) + + + + # Evernote Query: Last Updated Type: Absolute Time + evernote_query_last_updated_value_absolute_time = QTimeEdit() + evernote_query_last_updated_value_absolute_time.setDisplayFormat('h:mm AP') + evernote_query_last_updated_value_absolute_time.setVisible(False) + evernote_query_last_updated_value_absolute_time.setStyleSheet( + "QTimeEdit { font-weight: bold; color: rgb(143, 0, 30); } ") + evernote_query_last_updated_value_absolute_time.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_query_last_updated_value_absolute_time.connect(evernote_query_last_updated_value_absolute_time, + SIGNAL("timeChanged(QTime)"), + update_evernote_query_last_updated_value_absolute_time) + + hbox_datetime = QHBoxLayout() + hbox_datetime.addWidget(evernote_query_last_updated_value_absolute_date) + hbox_datetime.addWidget(evernote_query_last_updated_value_absolute_time) + + # Evernote Query: Last Updated Type + evernote_query_last_updated_value_stacked_layout = QStackedLayout() + evernote_query_last_updated_value_stacked_layout.addWidget(evernote_query_last_updated_value_relative_spinner) + evernote_query_last_updated_value_stacked_layout.addItem(hbox_datetime) + + # Evernote Query: Use Last Updated + evernote_query_use_last_updated = QCheckBox(" ", self) + evernote_query_use_last_updated.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + evernote_query_use_last_updated.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_LAST_UPDATED, False)) + evernote_query_use_last_updated.stateChanged.connect(update_evernote_query_use_last_updated) + + # Add Form Row for Last Updated + hbox = QHBoxLayout() + label = QLabel("Last Updated: ") + label.setMinimumWidth(100) + hbox.addWidget(evernote_query_use_last_updated) + hbox.addWidget(evernote_query_last_updated_type) + hbox.addWidget(evernote_query_last_updated_value_relative_spinner) + hbox.addWidget(evernote_query_last_updated_value_absolute_date) + hbox.addWidget(evernote_query_last_updated_value_absolute_time) + form.addRow(label, hbox) + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + ############################ PAGINATION ########################## + # Evernote Pagination: Current Page + evernote_pagination_current_page_spinner = QSpinBox() + evernote_pagination_current_page_spinner.setStyleSheet("QSpinBox { font-weight: bold; color: rgb(173, 0, 0); } ") + evernote_pagination_current_page_spinner.setPrefix("PAGE: ") + evernote_pagination_current_page_spinner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + evernote_pagination_current_page_spinner.setValue(mw.col.conf.get(SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE, 1)) + evernote_pagination_current_page_spinner.connect(evernote_pagination_current_page_spinner, + SIGNAL("valueChanged(int)"), + update_evernote_pagination_current_page_spinner) + + # Evernote Pagination: Auto Paging + evernote_pagination_auto_paging = QCheckBox(" Automate", self) + evernote_pagination_auto_paging.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + evernote_pagination_auto_paging.setFixedWidth(105) + evernote_pagination_auto_paging.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.AUTO_PAGING, True)) + evernote_pagination_auto_paging.stateChanged.connect(update_evernote_pagination_auto_paging) + + hbox = QHBoxLayout() + hbox.addWidget(evernote_pagination_auto_paging) + hbox.addWidget(evernote_pagination_current_page_spinner) + + # Add Form Row for Evernote Pagination + form.addRow("<b>Pagination:</b>", hbox) + + # Add Query Form to Group Box + group.setLayout(form) + + # Add Query Group Box to Main Layout + layout.addWidget(group) + + ########################## DECK ########################## + # label = QLabel("<span style='background-color: #bf0060;'><B><U>ANKI NOTE OPTIONS</U>:</B></span>") + group = QGroupBox("ANKI NOTE OPTIONS:") + group.setStyleSheet('QGroupBox{ font-size: 10px; font-weight: bold; color: rgb(105, 170, 53);}') + form = QFormLayout() + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + # Default Anki Deck + default_anki_deck = QLineEdit() + default_anki_deck.setText(mw.col.conf.get(SETTINGS.ANKI.DECKS.BASE, SETTINGS.ANKI.DECKS.BASE_DEFAULT_VALUE)) + default_anki_deck.connect(default_anki_deck, SIGNAL("textEdited(QString)"), update_default_anki_deck) + + # Add Form Row for Default Anki Deck + hbox = QHBoxLayout() + hbox.insertSpacing(0, 33) + hbox.addWidget(default_anki_deck) + label_deck = QLabel("<b>Anki Deck:</b>") + label_deck.setMinimumWidth(100) + form.addRow(label_deck, hbox) + + # Evernote Notebook Integration + anki_deck_evernote_notebook_integration = QCheckBox(" Append Evernote Notebook", self) + anki_deck_evernote_notebook_integration.setChecked( + mw.col.conf.get(SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION, True)) + anki_deck_evernote_notebook_integration.stateChanged.connect(update_anki_deck_evernote_notebook_integration) + + # Change Visibility of Deck Options + update_anki_deck_visibilities() + + # Add Form Row for Evernote Notebook Integration + label_deck = QLabel("Evernote Notebook:") + label_deck.setMinimumWidth(100) + form.addRow("", anki_deck_evernote_notebook_integration) + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + ############################ TAGS ########################## + # Keep Evernote Tags + keep_evernote_tags = QCheckBox(" Save To Anki Note", self) + keep_evernote_tags.setChecked( + mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE)) + keep_evernote_tags.stateChanged.connect(update_keep_evernote_tags) + + # Evernote Tags: Tags to Delete + evernote_tags_to_delete = QLineEdit() + evernote_tags_to_delete.setText(mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "")) + evernote_tags_to_delete.connect(evernote_tags_to_delete, + SIGNAL("textEdited(QString)"), + update_evernote_tags_to_delete) + + # Delete Tags To Import + delete_evernote_query_tags = QCheckBox(" Also Delete Search Tags", self) + delete_evernote_query_tags.setChecked(mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False)) + delete_evernote_query_tags.stateChanged.connect(update_delete_evernote_query_tags) + + # Add Form Row for Evernote Tag Options + label = QLabel("<b>Evernote Tags:</b>") + label.setMinimumWidth(100) + form.addRow(label, keep_evernote_tags) + hbox = QHBoxLayout() + hbox.insertSpacing(0, 33) + hbox.addWidget(evernote_tags_to_delete) + form.addRow("Tags to Delete:", hbox) + form.addRow(" ", delete_evernote_query_tags) + + # Add Horizontal Row Separator + form.addRow(gen_qt_hr()) + + ############################ NOTE UPDATING ########################## + # Note Update Method + update_existing_notes = QComboBox() + update_existing_notes.setStyleSheet( + ' QComboBox { color: #3b679e; font-weight: bold; } QComboBoxItem { color: #A40F2D; font-weight: bold; } ') + update_existing_notes.addItems(["Ignore Existing Notes", "Update In-Place", + "Delete and Re-Add"]) + update_existing_notes.setCurrentIndex(mw.col.conf.get(SETTINGS.ANKI.UPDATE_EXISTING_NOTES, + UpdateExistingNotes.UpdateNotesInPlace)) + update_existing_notes.activated.connect(update_update_existing_notes) + + # Add Form Row for Note Update Method + hbox = QHBoxLayout() + hbox.insertSpacing(0, 33) + hbox.addWidget(update_existing_notes) + form.addRow("<b>Note Updating:</b>", hbox) + + # Add Note Update Method Form to Group Box + group.setLayout(form) + + # Add Note Update Method Group Box to Main Layout + layout.addWidget(group) + + # Update Visibilities of Query Options + evernote_query_text_changed() + update_evernote_query_visibilities() + + + # Vertical Spacer + vertical_spacer = QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) + layout.addItem(vertical_spacer) + + # Parent Widget + widget.setLayout(layout) + + # New Tab + self.form.tabWidget.addTab(widget, "Anknotes") def gen_qt_hr(): - vbox = QVBoxLayout() - hr = QFrame() - hr.setAutoFillBackground(True) - hr.setFrameShape(QFrame.HLine) - hr.setStyleSheet("QFrame { background-color: #0060bf; color: #0060bf; }") - hr.setFixedHeight(2) - vbox.addWidget(hr) - vbox.addSpacing(4) - return vbox + vbox = QVBoxLayout() + hr = QFrame() + hr.setAutoFillBackground(True) + hr.setFrameShape(QFrame.HLine) + hr.setStyleSheet("QFrame { background-color: #0060bf; color: #0060bf; }") + hr.setFixedHeight(2) + vbox.addWidget(hr) + vbox.addSpacing(4) + return vbox def update_anki_deck_visibilities(): - if not default_anki_deck.text(): - anki_deck_evernote_notebook_integration.setChecked(True) - anki_deck_evernote_notebook_integration.setEnabled(False) - else: - anki_deck_evernote_notebook_integration.setEnabled(True) - anki_deck_evernote_notebook_integration.setChecked( - mw.col.conf.get(SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION, True)) + if not default_anki_deck.text(): + anki_deck_evernote_notebook_integration.setChecked(True) + anki_deck_evernote_notebook_integration.setEnabled(False) + else: + anki_deck_evernote_notebook_integration.setEnabled(True) + anki_deck_evernote_notebook_integration.setChecked( + mw.col.conf.get(SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION, True)) def update_default_anki_deck(text): - mw.col.conf[SETTINGS.ANKI.DECKS.BASE] = text - update_anki_deck_visibilities() + mw.col.conf[SETTINGS.ANKI.DECKS.BASE] = text + update_anki_deck_visibilities() def update_anki_deck_evernote_notebook_integration(): - if default_anki_deck.text(): - mw.col.conf[ - SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION] = anki_deck_evernote_notebook_integration.isChecked() + if default_anki_deck.text(): + mw.col.conf[ + SETTINGS.ANKI.DECKS.EVERNOTE_NOTEBOOK_INTEGRATION] = anki_deck_evernote_notebook_integration.isChecked() def update_evernote_tags_to_delete(text): - mw.col.conf[SETTINGS.ANKI.TAGS.TO_DELETE] = text + mw.col.conf[SETTINGS.ANKI.TAGS.TO_DELETE] = text def update_evernote_query_tags(text): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.TAGS] = text - if text: evernote_query_use_tags.setChecked(True) - evernote_query_text_changed() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.TAGS] = text + if text: evernote_query_use_tags.setChecked(True) + evernote_query_text_changed() def update_evernote_query_use_tags(): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_TAGS] = evernote_query_use_tags.isChecked() - update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_TAGS] = evernote_query_use_tags.isChecked() + update_evernote_query_visibilities() def update_evernote_query_excluded_tags(text): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS] = text - if text: evernote_query_use_excluded_tags.setChecked(True) - evernote_query_text_changed() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS] = text + if text: evernote_query_use_excluded_tags.setChecked(True) + evernote_query_text_changed() def update_evernote_query_use_excluded_tags(): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS] = evernote_query_use_excluded_tags.isChecked() - update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS] = evernote_query_use_excluded_tags.isChecked() + update_evernote_query_visibilities() def update_evernote_query_notebook(text): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.NOTEBOOK] = text - if text: evernote_query_use_notebook.setChecked(True) - evernote_query_text_changed() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.NOTEBOOK] = text + if text: evernote_query_use_notebook.setChecked(True) + evernote_query_text_changed() def update_evernote_query_use_notebook(): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK] = evernote_query_use_notebook.isChecked() - update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK] = evernote_query_use_notebook.isChecked() + update_evernote_query_visibilities() def update_evernote_query_note_title(text): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.NOTE_TITLE] = text - if text: evernote_query_use_note_title.setChecked(True) - evernote_query_text_changed() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.NOTE_TITLE] = text + if text: evernote_query_use_note_title.setChecked(True) + evernote_query_text_changed() def update_evernote_query_use_note_title(): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE] = evernote_query_use_note_title.isChecked() - update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE] = evernote_query_use_note_title.isChecked() + update_evernote_query_visibilities() def update_evernote_query_use_last_updated(): - update_evernote_query_visibilities() - mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_LAST_UPDATED] = evernote_query_use_last_updated.isChecked() + update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_LAST_UPDATED] = evernote_query_use_last_updated.isChecked() def update_evernote_query_search_terms(text): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.SEARCH_TERMS] = text - if text: evernote_query_use_search_terms.setChecked(True) - evernote_query_text_changed() - update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.SEARCH_TERMS] = text + if text: evernote_query_use_search_terms.setChecked(True) + evernote_query_text_changed() + update_evernote_query_visibilities() def update_evernote_query_use_search_terms(): - update_evernote_query_visibilities() - mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS] = evernote_query_use_search_terms.isChecked() + update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS] = evernote_query_use_search_terms.isChecked() def update_evernote_query_any(): - update_evernote_query_visibilities() - mw.col.conf[SETTINGS.EVERNOTE.QUERY.ANY] = evernote_query_any.isChecked() + update_evernote_query_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.ANY] = evernote_query_any.isChecked() def update_keep_evernote_tags(): - mw.col.conf[SETTINGS.ANKI.TAGS.KEEP_TAGS] = keep_evernote_tags.isChecked() - evernote_query_text_changed() + mw.col.conf[SETTINGS.ANKI.TAGS.KEEP_TAGS] = keep_evernote_tags.isChecked() + evernote_query_text_changed() def update_delete_evernote_query_tags(): - mw.col.conf[SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS] = delete_evernote_query_tags.isChecked() + mw.col.conf[SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS] = delete_evernote_query_tags.isChecked() def update_evernote_pagination_auto_paging(): - mw.col.conf[SETTINGS.EVERNOTE.AUTO_PAGING] = evernote_pagination_auto_paging.isChecked() + mw.col.conf[SETTINGS.EVERNOTE.AUTO_PAGING] = evernote_pagination_auto_paging.isChecked() def update_evernote_pagination_current_page_spinner(value): - if value < 1: - value = 1 - evernote_pagination_current_page_spinner.setValue(1) - mw.col.conf[SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE] = value + if value < 1: + value = 1 + evernote_pagination_current_page_spinner.setValue(1) + mw.col.conf[SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE] = value def update_update_existing_notes(index): - mw.col.conf[SETTINGS.ANKI.UPDATE_EXISTING_NOTES] = index + mw.col.conf[SETTINGS.ANKI.UPDATE_EXISTING_NOTES] = index def evernote_query_text_changed(): - tags = evernote_query_tags.text() - excluded_tags = evernote_query_excluded_tags.text() - search_terms = evernote_query_search_terms.text() - note_title = evernote_query_note_title.text() - notebook = evernote_query_notebook.text() - # tags_active = tags and evernote_query_use_tags.isChecked() - search_terms_active = search_terms and evernote_query_use_search_terms.isChecked() - note_title_active = note_title and evernote_query_use_note_title.isChecked() - notebook_active = notebook and evernote_query_use_notebook.isChecked() - excluded_tags_active = excluded_tags and evernote_query_use_excluded_tags.isChecked() - all_inactive = not ( - search_terms_active or note_title_active or notebook_active or excluded_tags_active or evernote_query_use_last_updated.isChecked()) - - if not search_terms: - evernote_query_use_search_terms.setEnabled(False) - evernote_query_use_search_terms.setChecked(False) - else: - evernote_query_use_search_terms.setEnabled(True) - evernote_query_use_search_terms.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS, True)) - - if not note_title: - evernote_query_use_note_title.setEnabled(False) - evernote_query_use_note_title.setChecked(False) - else: - evernote_query_use_note_title.setEnabled(True) - evernote_query_use_note_title.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE, True)) - - if not notebook: - evernote_query_use_notebook.setEnabled(False) - evernote_query_use_notebook.setChecked(False) - else: - evernote_query_use_notebook.setEnabled(True) - evernote_query_use_notebook.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK, True)) - - if not excluded_tags: - evernote_query_use_excluded_tags.setEnabled(False) - evernote_query_use_excluded_tags.setChecked(False) - else: - evernote_query_use_excluded_tags.setEnabled(True) - evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True)) - if not tags and not all_inactive: - evernote_query_use_tags.setEnabled(False) - evernote_query_use_tags.setChecked(False) - else: - evernote_query_use_tags.setEnabled(True) - evernote_query_use_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_TAGS, True)) - if all_inactive and not tags: - evernote_query_tags.setText(SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE) + tags = evernote_query_tags.text() + excluded_tags = evernote_query_excluded_tags.text() + search_terms = evernote_query_search_terms.text() + note_title = evernote_query_note_title.text() + notebook = evernote_query_notebook.text() + # tags_active = tags and evernote_query_use_tags.isChecked() + search_terms_active = search_terms and evernote_query_use_search_terms.isChecked() + note_title_active = note_title and evernote_query_use_note_title.isChecked() + notebook_active = notebook and evernote_query_use_notebook.isChecked() + excluded_tags_active = excluded_tags and evernote_query_use_excluded_tags.isChecked() + all_inactive = not ( + search_terms_active or note_title_active or notebook_active or excluded_tags_active or evernote_query_use_last_updated.isChecked()) + + if not search_terms: + evernote_query_use_search_terms.setEnabled(False) + evernote_query_use_search_terms.setChecked(False) + else: + evernote_query_use_search_terms.setEnabled(True) + evernote_query_use_search_terms.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS, True)) + + if not note_title: + evernote_query_use_note_title.setEnabled(False) + evernote_query_use_note_title.setChecked(False) + else: + evernote_query_use_note_title.setEnabled(True) + evernote_query_use_note_title.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE, True)) + + if not notebook: + evernote_query_use_notebook.setEnabled(False) + evernote_query_use_notebook.setChecked(False) + else: + evernote_query_use_notebook.setEnabled(True) + evernote_query_use_notebook.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK, True)) + + if not excluded_tags: + evernote_query_use_excluded_tags.setEnabled(False) + evernote_query_use_excluded_tags.setChecked(False) + else: + evernote_query_use_excluded_tags.setEnabled(True) + evernote_query_use_excluded_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True)) + if not tags and not all_inactive: + evernote_query_use_tags.setEnabled(False) + evernote_query_use_tags.setChecked(False) + else: + evernote_query_use_tags.setEnabled(True) + evernote_query_use_tags.setChecked(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_TAGS, True)) + if all_inactive and not tags: + evernote_query_tags.setText(SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE) def update_evernote_query_visibilities(): - # is_any = evernote_query_any.isChecked() - is_tags = evernote_query_use_tags.isChecked() - is_excluded_tags = evernote_query_use_excluded_tags.isChecked() - is_terms = evernote_query_use_search_terms.isChecked() - is_title = evernote_query_use_note_title.isChecked() - is_notebook = evernote_query_use_notebook.isChecked() - is_updated = evernote_query_use_last_updated.isChecked() - - # is_disabled_any = not evernote_query_any.isEnabled() - is_disabled_tags = not evernote_query_use_tags.isEnabled() - is_disabled_excluded_tags = not evernote_query_use_excluded_tags.isEnabled() - is_disabled_terms = not evernote_query_use_search_terms.isEnabled() - is_disabled_title = not evernote_query_use_note_title.isEnabled() - is_disabled_notebook = not evernote_query_use_notebook.isEnabled() - # is_disabled_updated = not evernote_query_use_last_updated.isEnabled() - - override = (not is_tags and not is_excluded_tags and not is_terms and not is_title and not is_notebook and not is_updated) - if override: - is_tags = True - evernote_query_use_tags.setChecked(True) - evernote_query_tags.setEnabled(is_tags or is_disabled_tags) - evernote_query_excluded_tags.setEnabled(is_excluded_tags or is_disabled_excluded_tags) - evernote_query_search_terms.setEnabled(is_terms or is_disabled_terms) - evernote_query_note_title.setEnabled(is_title or is_disabled_title) - evernote_query_notebook.setEnabled(is_notebook or is_disabled_notebook) - evernote_query_last_updated_value_set_visibilities() + # is_any = evernote_query_any.isChecked() + is_tags = evernote_query_use_tags.isChecked() + is_excluded_tags = evernote_query_use_excluded_tags.isChecked() + is_terms = evernote_query_use_search_terms.isChecked() + is_title = evernote_query_use_note_title.isChecked() + is_notebook = evernote_query_use_notebook.isChecked() + is_updated = evernote_query_use_last_updated.isChecked() + + # is_disabled_any = not evernote_query_any.isEnabled() + is_disabled_tags = not evernote_query_use_tags.isEnabled() + is_disabled_excluded_tags = not evernote_query_use_excluded_tags.isEnabled() + is_disabled_terms = not evernote_query_use_search_terms.isEnabled() + is_disabled_title = not evernote_query_use_note_title.isEnabled() + is_disabled_notebook = not evernote_query_use_notebook.isEnabled() + # is_disabled_updated = not evernote_query_use_last_updated.isEnabled() + + override = ( + not is_tags and not is_excluded_tags and not is_terms and not is_title and not is_notebook and not is_updated) + if override: + is_tags = True + evernote_query_use_tags.setChecked(True) + evernote_query_tags.setEnabled(is_tags or is_disabled_tags) + evernote_query_excluded_tags.setEnabled(is_excluded_tags or is_disabled_excluded_tags) + evernote_query_search_terms.setEnabled(is_terms or is_disabled_terms) + evernote_query_note_title.setEnabled(is_title or is_disabled_title) + evernote_query_notebook.setEnabled(is_notebook or is_disabled_notebook) + evernote_query_last_updated_value_set_visibilities() def update_evernote_query_last_updated_type(index): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE] = index - evernote_query_last_updated_value_set_visibilities() + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE] = index + evernote_query_last_updated_value_set_visibilities() def evernote_query_last_updated_value_get_current_value(): - index = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE, 0) - if index < EvernoteQueryLocationType.AbsoluteDate: - spinner_text = ['day', 'week', 'month', 'year'][index] - spinner_val = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_RELATIVE, 0) - if spinner_val > 0: spinner_text += "-" + str(spinner_val) - return spinner_text - - absolute_date_str = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE, - "{:%Y %m %d}".format(datetime.now() - timedelta(days=7))).replace(' ', '') - if index == EvernoteQueryLocationType.AbsoluteDate: - return absolute_date_str - absolute_time_str = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME, - "{:HH mm ss}".format(datetime.now())).replace(' ', '') - return absolute_date_str + "'T'" + absolute_time_str + index = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE, 0) + if index < EvernoteQueryLocationType.AbsoluteDate: + spinner_text = ['day', 'week', 'month', 'year'][index] + spinner_val = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_RELATIVE, 0) + if spinner_val > 0: spinner_text += "-" + str(spinner_val) + return spinner_text + + absolute_date_str = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE, + "{:%Y %m %d}".format(datetime.now() - timedelta(days=7))).replace(' ', '') + if index == EvernoteQueryLocationType.AbsoluteDate: + return absolute_date_str + absolute_time_str = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME, + "{:HH mm ss}".format(datetime.now())).replace(' ', '') + return absolute_date_str + "'T'" + absolute_time_str def evernote_query_last_updated_value_set_visibilities(): - index = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE, 0) - if not evernote_query_use_last_updated.isChecked(): - evernote_query_last_updated_type.setEnabled(False) - evernote_query_last_updated_value_absolute_date.setEnabled(False) - evernote_query_last_updated_value_absolute_time.setEnabled(False) - evernote_query_last_updated_value_relative_spinner.setEnabled(False) - return - - evernote_query_last_updated_type.setEnabled(True) - evernote_query_last_updated_value_absolute_date.setEnabled(True) - evernote_query_last_updated_value_absolute_time.setEnabled(True) - evernote_query_last_updated_value_relative_spinner.setEnabled(True) - - absolute_date = QDate().fromString(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE, - "{:%Y %m %d}".format(datetime.now() - timedelta(days=7))), - 'yyyy MM dd') - if index < EvernoteQueryLocationType.AbsoluteDate: - evernote_query_last_updated_value_absolute_date.setVisible(False) - evernote_query_last_updated_value_absolute_time.setVisible(False) - evernote_query_last_updated_value_relative_spinner.setVisible(True) - spinner_prefix = ['day', 'week', 'month', 'year'][index] - evernote_query_last_updated_value_relative_spinner.setPrefix(spinner_prefix) - evernote_query_last_updated_value_relative_spinner.setValue( - int(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_RELATIVE, 0))) - evernote_query_last_updated_value_stacked_layout.setCurrentIndex(0) - else: - evernote_query_last_updated_value_relative_spinner.setVisible(False) - evernote_query_last_updated_value_absolute_date.setVisible(True) - evernote_query_last_updated_value_absolute_date.setDate(absolute_date) - evernote_query_last_updated_value_stacked_layout.setCurrentIndex(1) - if index == EvernoteQueryLocationType.AbsoluteDate: - evernote_query_last_updated_value_absolute_time.setVisible(False) - evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - else: - absolute_time = QTime().fromString(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME, - "{:HH mm ss}".format(datetime.now())), 'HH mm ss') - evernote_query_last_updated_value_absolute_time.setTime(absolute_time) - evernote_query_last_updated_value_absolute_time.setVisible(True) - evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + index = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_TYPE, 0) + if not evernote_query_use_last_updated.isChecked(): + evernote_query_last_updated_type.setEnabled(False) + evernote_query_last_updated_value_absolute_date.setEnabled(False) + evernote_query_last_updated_value_absolute_time.setEnabled(False) + evernote_query_last_updated_value_relative_spinner.setEnabled(False) + return + + evernote_query_last_updated_type.setEnabled(True) + evernote_query_last_updated_value_absolute_date.setEnabled(True) + evernote_query_last_updated_value_absolute_time.setEnabled(True) + evernote_query_last_updated_value_relative_spinner.setEnabled(True) + + absolute_date = QDate().fromString(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE, + "{:%Y %m %d}".format(datetime.now() - timedelta(days=7))), + 'yyyy MM dd') + if index < EvernoteQueryLocationType.AbsoluteDate: + evernote_query_last_updated_value_absolute_date.setVisible(False) + evernote_query_last_updated_value_absolute_time.setVisible(False) + evernote_query_last_updated_value_relative_spinner.setVisible(True) + spinner_prefix = ['day', 'week', 'month', 'year'][index] + evernote_query_last_updated_value_relative_spinner.setPrefix(spinner_prefix) + evernote_query_last_updated_value_relative_spinner.setValue( + int(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_RELATIVE, 0))) + evernote_query_last_updated_value_stacked_layout.setCurrentIndex(0) + else: + evernote_query_last_updated_value_relative_spinner.setVisible(False) + evernote_query_last_updated_value_absolute_date.setVisible(True) + evernote_query_last_updated_value_absolute_date.setDate(absolute_date) + evernote_query_last_updated_value_stacked_layout.setCurrentIndex(1) + if index == EvernoteQueryLocationType.AbsoluteDate: + evernote_query_last_updated_value_absolute_time.setVisible(False) + evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + else: + absolute_time = QTime().fromString(mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME, + "{:HH mm ss}".format(datetime.now())), 'HH mm ss') + evernote_query_last_updated_value_absolute_time.setTime(absolute_time) + evernote_query_last_updated_value_absolute_time.setVisible(True) + evernote_query_last_updated_value_absolute_datetime.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) def update_evernote_query_last_updated_value_relative_spinner(value): - if value < 0: - value = 0 - evernote_query_last_updated_value_relative_spinner.setValue(0) - mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_RELATIVE] = value + if value < 0: + value = 0 + evernote_query_last_updated_value_relative_spinner.setValue(0) + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_RELATIVE] = value def update_evernote_query_last_updated_value_absolute_date(date): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE] = date.toString('yyyy MM dd') + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE] = date.toString('yyyy MM dd') def update_evernote_query_last_updated_value_absolute_datetime(dt): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE] = dt.toString('yyyy MM dd') - mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME] = dt.toString('HH mm ss') + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_DATE] = dt.toString('yyyy MM dd') + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME] = dt.toString('HH mm ss') def update_evernote_query_last_updated_value_absolute_time(time_value): - mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME] = time_value.toString('HH mm ss') + mw.col.conf[SETTINGS.EVERNOTE.QUERY.LAST_UPDATED_VALUE_ABSOLUTE_TIME] = time_value.toString('HH mm ss') def generate_evernote_query(): - query = "" - if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK, False): - query += 'notebook:"%s" ' % mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTEBOOK, - SETTINGS.EVERNOTE.QUERY.NOTEBOOK_DEFAULT_VALUE).strip() - if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.ANY, True): - query += "any: " - if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE, False): - query_note_title = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTE_TITLE, "") - if not query_note_title[:1] + query_note_title[-1:] == '""': - query_note_title = '"%s"' % query_note_title - query += 'intitle:%s ' % query_note_title - if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_TAGS, True): - tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() - for tag in tags: - tag = tag.strip() - if ' ' in tag: tag = '"%s"' % tag - query += 'tag:%s ' % tag - if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True): - tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS, '').replace(',', ' ').split() - for tag in tags: - tag = tag.strip() - if ' ' in tag: tag = '"%s"' % tag - query += '-tag:%s ' % tag - if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_LAST_UPDATED, False): - query += " updated:%s " % evernote_query_last_updated_value_get_current_value() - if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS, False): - query += mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.SEARCH_TERMS, "") - return query + query = "" + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTEBOOK, False): + query += 'notebook:"%s" ' % mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTEBOOK, + SETTINGS.EVERNOTE.QUERY.NOTEBOOK_DEFAULT_VALUE).strip() + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.ANY, True): + query += "any: " + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_NOTE_TITLE, False): + query_note_title = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.NOTE_TITLE, "") + if not query_note_title[:1] + query_note_title[-1:] == '""': + query_note_title = '"%s"' % query_note_title + query += 'intitle:%s ' % query_note_title + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_TAGS, True): + tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', + ' ').split() + for tag in tags: + tag = tag.strip() + if ' ' in tag: tag = '"%s"' % tag + query += 'tag:%s ' % tag + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_EXCLUDED_TAGS, True): + tags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.EXCLUDED_TAGS, '').replace(',', ' ').split() + for tag in tags: + tag = tag.strip() + if ' ' in tag: tag = '"%s"' % tag + query += '-tag:%s ' % tag + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_LAST_UPDATED, False): + query += " updated:%s " % evernote_query_last_updated_value_get_current_value() + if mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.USE_SEARCH_TERMS, False): + query += mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.SEARCH_TERMS, "") + return query def handle_show_generated_evernote_query(): - showInfo( - "The Evernote search query for your current options is below. You can press copy the text to your clipboard by pressing the copy keyboard shortcut (CTRL+C in Windows) while this message box has focus.\n\nQuery: %s" % generate_evernote_query(), - "Evernote Search Query") + showInfo( + "The Evernote search query for your current options is below. You can press copy the text to your clipboard by pressing the copy keyboard shortcut (CTRL+C in Windows) while this message box has focus.\n\nQuery: %s" % generate_evernote_query(), + "Evernote Search Query") diff --git a/anknotes/shared.py b/anknotes/shared.py index 47d3c47..6f0acfd 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- ### Python Imports try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite import os import re import sys +from datetime import datetime ### Check if in Anki -inAnki='anki' in sys.modules +inAnki = 'anki' in sys.modules ### Anknotes Imports from anknotes.constants import * from anknotes.logging import * @@ -18,99 +19,121 @@ ### Anki and Evernote Imports if inAnki: - from aqt import mw - from aqt.qt import QIcon, QPixmap, QPushButton, QMessageBox - from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ - EDAMNotFoundException + from aqt import mw + from aqt.qt import QIcon, QPixmap, QPushButton, QMessageBox + from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ + EDAMNotFoundException + def get_friendly_interval_string(lastImport): - if not lastImport: return "" - td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) - days = td.days - hours, remainder = divmod(td.total_seconds(), 3600) - minutes, seconds = divmod(remainder, 60) - if days > 1: - lastImportStr = "%d days" % td.days - else: - hours = round(hours) - hours_str = '' if hours == 0 else ('1:%02d hr' % minutes) if hours == 1 else '%d Hours' % hours - if days == 1: - lastImportStr = "One Day%s" % ('' if hours == 0 else ', ' + hours_str) - elif hours > 0: - lastImportStr = hours_str - else: - lastImportStr = "%d:%02d min" % (minutes, seconds) - return lastImportStr + if not lastImport: return "" + td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) + days = td.days + hours, remainder = divmod(td.total_seconds(), 3600) + minutes, seconds = divmod(remainder, 60) + if days > 1: + lastImportStr = "%d days" % td.days + else: + hours = round(hours) + hours_str = '' if hours == 0 else ('1:%02d hr' % minutes) if hours == 1 else '%d Hours' % hours + if days == 1: + lastImportStr = "One Day%s" % ('' if hours == 0 else ', ' + hours_str) + elif hours > 0: + lastImportStr = hours_str + else: + lastImportStr = "%d:%02d min" % (minutes, seconds) + return lastImportStr + def clean_evernote_css(strr): - remove_style_attrs = '-webkit-text-size-adjust: auto|-webkit-text-stroke-width: 0px|background-color: rgb(255, 255, 255)|color: rgb(0, 0, 0)|font-family: Tahoma|font-size: medium;|font-style: normal|font-variant: normal|font-weight: normal|letter-spacing: normal|orphans: 2|text-align: -webkit-auto|text-indent: 0px|text-transform: none|white-space: normal|widows: 2|word-spacing: 0px|word-wrap: break-word|-webkit-nbsp-mode: space|-webkit-line-break: after-white-space'.replace( - '(', '\\(').replace(')', '\\)') - # 'margin: 0px; padding: 0px 0px 0px 40px; ' - return re.sub(r' ?(%s);? ?' % remove_style_attrs, '', strr).replace(' style=""', '') + remove_style_attrs = '-webkit-text-size-adjust: auto|-webkit-text-stroke-width: 0px|background-color: rgb(255, 255, 255)|color: rgb(0, 0, 0)|font-family: Tahoma|font-size: medium;|font-style: normal|font-variant: normal|font-weight: normal|letter-spacing: normal|orphans: 2|text-align: -webkit-auto|text-indent: 0px|text-transform: none|white-space: normal|widows: 2|word-spacing: 0px|word-wrap: break-word|-webkit-nbsp-mode: space|-webkit-line-break: after-white-space'.replace( + '(', '\\(').replace(')', '\\)') + # 'margin: 0px; padding: 0px 0px 0px 40px; ' + return re.sub(r' ?(%s);? ?' % remove_style_attrs, '', strr).replace(' style=""', '') + + class UpdateExistingNotes: - IgnoreExistingNotes, UpdateNotesInPlace, DeleteAndReAddNotes = range(3) + IgnoreExistingNotes, UpdateNotesInPlace, DeleteAndReAddNotes = range(3) class EvernoteQueryLocationType: - RelativeDay, RelativeWeek, RelativeMonth, RelativeYear, AbsoluteDate, AbsoluteDateTime = range(6) + RelativeDay, RelativeWeek, RelativeMonth, RelativeYear, AbsoluteDate, AbsoluteDateTime = range(6) + def __check_tag_name__(v, tags_to_delete): - return v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete) and (not hasattr(v, 'name') or getattr(v, 'name') not in tags_to_delete) - -def get_tag_names_to_import(tagNames, evernoteQueryTags=None, evernoteTagsToDelete=None, keepEvernoteTags=None, deleteEvernoteQueryTags=None): - if keepEvernoteTags is None: keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE) - if not keepEvernoteTags: return {} if isinstance(tagNames, dict) else [] - if evernoteQueryTags is None: evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace(',', ' ').split() - if deleteEvernoteQueryTags is None: deleteEvernoteQueryTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False) - if evernoteTagsToDelete is None: evernoteTagsToDelete = mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "").replace(',', ' ').split() - tags_to_delete = evernoteQueryTags if deleteEvernoteQueryTags else [] + evernoteTagsToDelete - if isinstance(tagNames, dict): - return {k: v for k, v in tagNames.items() if __check_tag_name__(v, tags_to_delete)} - return sorted([v for v in tagNames if __check_tag_name__(v, tags_to_delete)]) + return v not in tags_to_delete and (not hasattr(v, 'Name') or getattr(v, 'Name') not in tags_to_delete) and ( + not hasattr(v, 'name') or getattr(v, 'name') not in tags_to_delete) + + +def get_tag_names_to_import(tagNames, evernoteQueryTags=None, evernoteTagsToDelete=None, keepEvernoteTags=None, + deleteEvernoteQueryTags=None): + if keepEvernoteTags is None: keepEvernoteTags = mw.col.conf.get(SETTINGS.ANKI.TAGS.KEEP_TAGS, + SETTINGS.ANKI.TAGS.KEEP_TAGS_DEFAULT_VALUE) + if not keepEvernoteTags: return {} if isinstance(tagNames, dict) else [] + if evernoteQueryTags is None: evernoteQueryTags = mw.col.conf.get(SETTINGS.EVERNOTE.QUERY.TAGS, + SETTINGS.EVERNOTE.QUERY.TAGS_DEFAULT_VALUE).replace( + ',', ' ').split() + if deleteEvernoteQueryTags is None: deleteEvernoteQueryTags = mw.col.conf.get( + SETTINGS.ANKI.TAGS.DELETE_EVERNOTE_QUERY_TAGS, False) + if evernoteTagsToDelete is None: evernoteTagsToDelete = mw.col.conf.get(SETTINGS.ANKI.TAGS.TO_DELETE, "").replace( + ',', ' ').split() + tags_to_delete = evernoteQueryTags if deleteEvernoteQueryTags else [] + evernoteTagsToDelete + if isinstance(tagNames, dict): + return {k: v for k, v in tagNames.items() if __check_tag_name__(v, tags_to_delete)} + return sorted([v for v in tagNames if __check_tag_name__(v, tags_to_delete)]) + def find_evernote_guids(content): - return [x.group('guid') for x in re.finditer(r'\b(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b', content)] + return [x.group('guid') for x in + re.finditer(r'\b(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b', content)] + def find_evernote_links_as_guids(content): - return [x.Guid for x in find_evernote_links(content)] + return [x.Guid for x in find_evernote_links(content)] + def replace_evernote_web_links(content): - return re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', - r'evernote:///view/\2/\1/\3/\3/', content) + return re.sub( + r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', + r'evernote:///view/\2/\1/\3/\3/', content) + def find_evernote_links(content): - """ - - :param content: - :return: - :rtype : list[EvernoteLink] - """ - # .NET regex saved to regex.txt as 'Finding Evernote Links' - content = replace_evernote_web_links(content) - regex_str = r"""(?si)<a href=["'](?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)["''](?:[^>]+)?>(?P<title>.+?)</a>""" - ids = get_evernote_account_ids() - if not ids.Valid: - match = re.search(regex_str, content) - if match: - ids.update(match.group('uid'), match.group('shard')) - return [EvernoteLink(m) for m in re.finditer(regex_str, content)] + """ + + :param content: + :return: + :rtype : list[EvernoteLink] + """ + # .NET regex saved to regex.txt as 'Finding Evernote Links' + content = replace_evernote_web_links(content) + regex_str = r"""(?si)<a href=["'](?P<URL>evernote:///?view/(?P<uid>[\d]+?)/(?P<shard>s\d+)/(?P<guid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P=guid)/?)["''](?:[^>]+)?>(?P<title>.+?)</a>""" + ids = get_evernote_account_ids() + if not ids.Valid: + match = re.search(regex_str, content) + if match: + ids.update(match.group('uid'), match.group('shard')) + return [EvernoteLink(m) for m in re.finditer(regex_str, content)] + def get_dict_from_list(lst, keys_to_ignore=list()): - dic = {} - for key, value in lst: - if not key in keys_to_ignore: dic[key] = value - return dic + dic = {} + for key, value in lst: + if not key in keys_to_ignore: dic[key] = value + return dic + _regex_see_also = None + def update_regex(): - global _regex_see_also - regex_str = file(os.path.join(FOLDERS.ANCILLARY, 'regex-see_also.txt'), 'r').read() - regex_str = regex_str.replace('(?<', '(?P<') - _regex_see_also = re.compile(regex_str, re.UNICODE | re.VERBOSE | re.DOTALL) + global _regex_see_also + regex_str = file(os.path.join(FOLDERS.ANCILLARY, 'regex-see_also.txt'), 'r').read() + regex_str = regex_str.replace('(?<', '(?P<') + _regex_see_also = re.compile(regex_str, re.UNICODE | re.VERBOSE | re.DOTALL) def regex_see_also(): - global _regex_see_also - if not _regex_see_also: update_regex() - return _regex_see_also + global _regex_see_also + if not _regex_see_also: update_regex() + return _regex_see_also diff --git a/anknotes/structs.py b/anknotes/structs.py index fb8ea3a..dab320e 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -10,694 +10,722 @@ # from evernote.edam.notestore.ttypes import NoteMetadata, NotesMetadataList def upperFirst(name): - return name[0].upper() + name[1:] + return name[0].upper() + name[1:] + def getattrcallable(obj, attr): - val = getattr(obj, attr) - if callable(val): return val() - return val + val = getattr(obj, attr) + if callable(val): return val() + return val + # from anknotes.EvernoteNotePrototype import EvernoteNotePrototype # from anknotes.EvernoteNoteTitle import EvernoteNoteTitle class EvernoteStruct(object): - success = False - Name = "" - Guid = "" - __sql_columns__ = "name" - __sql_table__ = TABLES.EVERNOTE.TAGS - __sql_where__ = "guid" - __attr_order__ = [] - __title_is_note_title = False - - @staticmethod - def __attr_from_key__(key): - return upperFirst(key) - - def keys(self): - return self._valid_attributes_() - - def items(self): - return [self.getAttribute(key) for key in self.__attr_order__] - - def sqlUpdateQuery(self): - columns = self.__attr_order__ if self.__attr_order__ else self.__sql_columns__ - return "INSERT OR REPLACE INTO `%s`(%s) VALUES (%s)" % (self.__sql_table__, '`' + '`,`'.join(columns) + '`', ', '.join(['?']*len(columns))) - - def sqlSelectQuery(self, allColumns=True): - return "SELECT %s FROM %s WHERE %s = '%s'" % ( - '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) - - def getFromDB(self, allColumns=True): - query = "SELECT %s FROM %s WHERE %s = '%s'" % ( - '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) - ankDB().setrowfactory() - result = ankDB().first(self.sqlSelectQuery(allColumns)) - if result: - self.success = True - self.setFromKeyedObject(result) - else: - self.success = False - return self.success - - @property - def Where(self): - return self.getAttribute(self.__sql_where__) - - @Where.setter - def Where(self, value): - self.setAttribute(self.__sql_where__, value) - - def getAttribute(self, key, default=None, raiseIfInvalidKey=False): - if not self.hasAttribute(key): - if raiseIfInvalidKey: raise KeyError - return default - return getattr(self, self.__attr_from_key__(key)) - - def hasAttribute(self, key): - return hasattr(self, self.__attr_from_key__(key)) - - def setAttribute(self, key, value): - if key == "fetch_" + self.__sql_where__: - self.setAttribute(self.__sql_where__, value) - self.getFromDB() - elif self._is_valid_attribute_(key): - setattr(self, self.__attr_from_key__(key), value) - else: - raise KeyError("%s: %s is not a valid attribute" % (self.__class__.__name__, key)) - - def setAttributeByObject(self, key, keyed_object): - self.setAttribute(key, keyed_object[key]) - - def setFromKeyedObject(self, keyed_object, keys=None): - """ - - :param keyed_object: - :type: sqlite.Row | dict[str, object] | re.MatchObject | _sre.SRE_Match - :return: - """ - lst = self._valid_attributes_() - if keys or isinstance(keyed_object, dict): - pass - elif isinstance(keyed_object, type(re.search('', ''))): - keyed_object = keyed_object.groupdict() - elif hasattr(keyed_object, 'keys'): - keys = getattrcallable(keyed_object, 'keys') - elif hasattr(keyed_object, self.__sql_where__): - for key in self.keys(): - if hasattr(keyed_object, key): self.setAttribute(key, getattr(keyed_object, key)) - return True - else: - return False - - if keys is None: keys = keyed_object - for key in keys: - if key == "fetch_" + self.__sql_where__: - self.Where = keyed_object[key] - self.getFromDB() - elif key in lst: self.setAttributeByObject(key, keyed_object) - return True - - def setFromListByDefaultOrder(self, args): - max = len(self.__attr_order__) - for i, value in enumerate(args): - if i > max: - raise Exception("Argument #%d for %s (%s) exceeds the default number of attributes for the class." % (i, self.__class__.__name__, str(value))) - self.setAttribute(self.__attr_order__[i], value) - - def _valid_attributes_(self): - return set().union(self.__sql_columns__, [self.__sql_where__], self.__attr_order__) - - def _is_valid_attribute_(self, attribute): - return (attribute[0].lower() + attribute[1:]) in self._valid_attributes_() - - def __init__(self, *args, **kwargs): - if isinstance(self.__sql_columns__, str): self.__sql_columns__ = [self.__sql_columns__] - if isinstance(self.__attr_order__, str) or isinstance(self.__attr_order__, unicode): - self.__attr_order__ = self.__attr_order__.replace('|', ' ').split(' ') - args = list(args) - if args and self.setFromKeyedObject(args[0]): del args[0] - self.setFromListByDefaultOrder(args) - self.setFromKeyedObject(kwargs) + success = False + Name = "" + Guid = "" + __sql_columns__ = "name" + __sql_table__ = TABLES.EVERNOTE.TAGS + __sql_where__ = "guid" + __attr_order__ = [] + __title_is_note_title = False + + @staticmethod + def __attr_from_key__(key): + return upperFirst(key) + + def keys(self): + return self._valid_attributes_() + + def items(self): + return [self.getAttribute(key) for key in self.__attr_order__] + + def sqlUpdateQuery(self): + columns = self.__attr_order__ if self.__attr_order__ else self.__sql_columns__ + return "INSERT OR REPLACE INTO `%s`(%s) VALUES (%s)" % ( + self.__sql_table__, '`' + '`,`'.join(columns) + '`', ', '.join(['?'] * len(columns))) + + def sqlSelectQuery(self, allColumns=True): + return "SELECT %s FROM %s WHERE %s = '%s'" % ( + '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) + + def getFromDB(self, allColumns=True): + query = "SELECT %s FROM %s WHERE %s = '%s'" % ( + '*' if allColumns else ','.join(self.__sql_columns__), self.__sql_table__, self.__sql_where__, self.Where) + ankDB().setrowfactory() + result = ankDB().first(self.sqlSelectQuery(allColumns)) + if result: + self.success = True + self.setFromKeyedObject(result) + else: + self.success = False + return self.success + + @property + def Where(self): + return self.getAttribute(self.__sql_where__) + + @Where.setter + def Where(self, value): + self.setAttribute(self.__sql_where__, value) + + def getAttribute(self, key, default=None, raiseIfInvalidKey=False): + if not self.hasAttribute(key): + if raiseIfInvalidKey: raise KeyError + return default + return getattr(self, self.__attr_from_key__(key)) + + def hasAttribute(self, key): + return hasattr(self, self.__attr_from_key__(key)) + + def setAttribute(self, key, value): + if key == "fetch_" + self.__sql_where__: + self.setAttribute(self.__sql_where__, value) + self.getFromDB() + elif self._is_valid_attribute_(key): + setattr(self, self.__attr_from_key__(key), value) + else: + raise KeyError("%s: %s is not a valid attribute" % (self.__class__.__name__, key)) + + def setAttributeByObject(self, key, keyed_object): + self.setAttribute(key, keyed_object[key]) + + def setFromKeyedObject(self, keyed_object, keys=None): + """ + + :param keyed_object: + :type: sqlite.Row | dict[str, object] | re.MatchObject | _sre.SRE_Match + :return: + """ + lst = self._valid_attributes_() + if keys or isinstance(keyed_object, dict): + pass + elif isinstance(keyed_object, type(re.search('', ''))): + keyed_object = keyed_object.groupdict() + elif hasattr(keyed_object, 'keys'): + keys = getattrcallable(keyed_object, 'keys') + elif hasattr(keyed_object, self.__sql_where__): + for key in self.keys(): + if hasattr(keyed_object, key): self.setAttribute(key, getattr(keyed_object, key)) + return True + else: + return False + + if keys is None: keys = keyed_object + for key in keys: + if key == "fetch_" + self.__sql_where__: + self.Where = keyed_object[key] + self.getFromDB() + elif key in lst: + self.setAttributeByObject(key, keyed_object) + return True + + def setFromListByDefaultOrder(self, args): + max = len(self.__attr_order__) + for i, value in enumerate(args): + if i > max: + raise Exception("Argument #%d for %s (%s) exceeds the default number of attributes for the class." % ( + i, self.__class__.__name__, str(value))) + self.setAttribute(self.__attr_order__[i], value) + + def _valid_attributes_(self): + return set().union(self.__sql_columns__, [self.__sql_where__], self.__attr_order__) + + def _is_valid_attribute_(self, attribute): + return (attribute[0].lower() + attribute[1:]) in self._valid_attributes_() + + def __init__(self, *args, **kwargs): + if isinstance(self.__sql_columns__, str): self.__sql_columns__ = [self.__sql_columns__] + if isinstance(self.__attr_order__, str) or isinstance(self.__attr_order__, unicode): + self.__attr_order__ = self.__attr_order__.replace('|', ' ').split(' ') + args = list(args) + if args and self.setFromKeyedObject(args[0]): del args[0] + self.setFromListByDefaultOrder(args) + self.setFromKeyedObject(kwargs) + class EvernoteNotebook(EvernoteStruct): - Stack = "" - __sql_columns__ = ["name", "stack"] - __sql_table__ = TABLES.EVERNOTE.NOTEBOOKS + Stack = "" + __sql_columns__ = ["name", "stack"] + __sql_table__ = TABLES.EVERNOTE.NOTEBOOKS class EvernoteTag(EvernoteStruct): - ParentGuid = "" - UpdateSequenceNum = -1 - __sql_columns__ = ["name", "parentGuid"] - __sql_table__ = TABLES.EVERNOTE.TAGS - __attr_order__ = 'guid|name|parentGuid|updateSequenceNum' + ParentGuid = "" + UpdateSequenceNum = -1 + __sql_columns__ = ["name", "parentGuid"] + __sql_table__ = TABLES.EVERNOTE.TAGS + __attr_order__ = 'guid|name|parentGuid|updateSequenceNum' class EvernoteLink(EvernoteStruct): - __uid__ = -1 - Shard = 'x999' - Guid = "" - __title__ = None - """:type: EvernoteNoteTitle.EvernoteNoteTitle """ - __attr_order__ = 'uid|shard|guid|title' - - @property - def HTML(self): - return self.Title.HTML - - @property - def Title(self): - """:rtype : EvernoteNoteTitle.EvernoteNoteTitle""" - return self.__title__ - - @property - def FullTitle(self): return self.Title.FullTitle - - @Title.setter - def Title(self, value): - """ - :param value: - :type value : EvernoteNoteTitle.EvernoteNoteTitle | str | unicode - :return: - """ - self.__title__ = anknotes.EvernoteNoteTitle.EvernoteNoteTitle(value) - """:type : EvernoteNoteTitle.EvernoteNoteTitle""" - - @property - def Uid(self): - return int(self.__uid__) - - @Uid.setter - def Uid(self, value): - self.__uid__ = int(value) + __uid__ = -1 + Shard = 'x999' + Guid = "" + __title__ = None + """:type: EvernoteNoteTitle.EvernoteNoteTitle """ + __attr_order__ = 'uid|shard|guid|title' + + @property + def HTML(self): + return self.Title.HTML + + @property + def Title(self): + """:rtype : EvernoteNoteTitle.EvernoteNoteTitle""" + return self.__title__ + + @property + def FullTitle(self): return self.Title.FullTitle + + @Title.setter + def Title(self, value): + """ + :param value: + :type value : EvernoteNoteTitle.EvernoteNoteTitle | str | unicode + :return: + """ + self.__title__ = anknotes.EvernoteNoteTitle.EvernoteNoteTitle(value) + """:type : EvernoteNoteTitle.EvernoteNoteTitle""" + + @property + def Uid(self): + return int(self.__uid__) + + @Uid.setter + def Uid(self, value): + self.__uid__ = int(value) + class EvernoteTOCEntry(EvernoteStruct): - RealTitle = "" - """:type : str""" - OrderedList = "" - """ - HTML output of Root Title's Ordererd List - :type : str - """ - TagNames = "" - """:type : str""" - NotebookGuid = "" - def __init__(self, *args, **kwargs): - self.__attr_order__ = 'realTitle|orderedList|tagNames|notebookGuid' - super(self.__class__, self).__init__(*args, **kwargs) + RealTitle = "" + """:type : str""" + OrderedList = "" + """ + HTML output of Root Title's Ordererd List + :type : str + """ + TagNames = "" + """:type : str""" + NotebookGuid = "" + + def __init__(self, *args, **kwargs): + self.__attr_order__ = 'realTitle|orderedList|tagNames|notebookGuid' + super(self.__class__, self).__init__(*args, **kwargs) class EvernoteValidationEntry(EvernoteStruct): - Guid = "" - """:type : str""" - Title = "" - """:type : str""" - Contents = "" - """:type : str""" - TagNames = "" - """:type : str""" - NotebookGuid = "" - - def __init__(self, *args, **kwargs): - # spr = super(self.__class__ , self) - # spr.__attr_order__ = self.__attr_order__ - # spr.__init__(*args, **kwargs) - self.__attr_order__ = 'guid|title|contents|tagNames|notebookGuid' - super(self.__class__, self).__init__(*args, **kwargs) + Guid = "" + """:type : str""" + Title = "" + """:type : str""" + Contents = "" + """:type : str""" + TagNames = "" + """:type : str""" + NotebookGuid = "" + + def __init__(self, *args, **kwargs): + # spr = super(self.__class__ , self) + # spr.__attr_order__ = self.__attr_order__ + # spr.__init__(*args, **kwargs) + self.__attr_order__ = 'guid|title|contents|tagNames|notebookGuid' + super(self.__class__, self).__init__(*args, **kwargs) class EvernoteAPIStatusOld(AutoNumber): - Uninitialized = -100 - """:type : EvernoteAPIStatus""" - EmptyRequest = -3 - """:type : EvernoteAPIStatus""" - Manual = -2 - """:type : EvernoteAPIStatus""" - RequestQueued = -1 - """:type : EvernoteAPIStatus""" - Success = 0 - """:type : EvernoteAPIStatus""" - RateLimitError = () - """:type : EvernoteAPIStatus""" - SocketError = () - """:type : EvernoteAPIStatus""" - UserError = () - """:type : EvernoteAPIStatus""" - NotFoundError = () - """:type : EvernoteAPIStatus""" - UnhandledError = () - """:type : EvernoteAPIStatus""" - Unknown = 100 - """:type : EvernoteAPIStatus""" - - def __getitem__(self, item): - """:rtype : EvernoteAPIStatus""" - - return super(self.__class__, self).__getitem__(item) - - # def __new__(cls, *args, **kwargs): - # """:rtype : EvernoteAPIStatus""" - # return type(cls).__new__(*args, **kwargs) - - @property - def IsError(self): - return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value - - @property - def IsSuccessful(self): - return EvernoteAPIStatus.Success.value >= self.value > EvernoteAPIStatus.Uninitialized.value - - @property - def IsSuccess(self): - return self == EvernoteAPIStatus.Success - + Uninitialized = -100 + """:type : EvernoteAPIStatus""" + EmptyRequest = -3 + """:type : EvernoteAPIStatus""" + Manual = -2 + """:type : EvernoteAPIStatus""" + RequestQueued = -1 + """:type : EvernoteAPIStatus""" + Success = 0 + """:type : EvernoteAPIStatus""" + RateLimitError = () + """:type : EvernoteAPIStatus""" + SocketError = () + """:type : EvernoteAPIStatus""" + UserError = () + """:type : EvernoteAPIStatus""" + NotFoundError = () + """:type : EvernoteAPIStatus""" + UnhandledError = () + """:type : EvernoteAPIStatus""" + Unknown = 100 + """:type : EvernoteAPIStatus""" + + def __getitem__(self, item): + """:rtype : EvernoteAPIStatus""" + + return super(self.__class__, self).__getitem__(item) + + # def __new__(cls, *args, **kwargs): + # """:rtype : EvernoteAPIStatus""" + # return type(cls).__new__(*args, **kwargs) + + @property + def IsError(self): + return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value + + @property + def IsSuccessful(self): + return EvernoteAPIStatus.Success.value >= self.value > EvernoteAPIStatus.Uninitialized.value + + @property + def IsSuccess(self): + return self == EvernoteAPIStatus.Success class EvernoteAPIStatus(AutoNumberedEnum): - Uninitialized = -100 - """:type : EvernoteAPIStatus""" - Initialized = -75 - """:type : EvernoteAPIStatus""" - UnableToFindStatus = -70 - """:type : EvernoteAPIStatus""" - InvalidStatus = -60 - """:type : EvernoteAPIStatus""" - Cancelled = -50 - """:type : EvernoteAPIStatus""" - Disabled = -25 - """:type : EvernoteAPIStatus""" - EmptyRequest = -10 - """:type : EvernoteAPIStatus""" - Manual = -5 - """:type : EvernoteAPIStatus""" - RequestSkipped = -4 - """:type : EvernoteAPIStatus""" - RequestQueued = -3 - """:type : EvernoteAPIStatus""" - ExceededLocalLimit = -2 - """:type : EvernoteAPIStatus""" - DelayedDueToRateLimit = -1 - """:type : EvernoteAPIStatus""" - Success = 0 - """:type : EvernoteAPIStatus""" - RateLimitError = () - """:type : EvernoteAPIStatus""" - SocketError = () - """:type : EvernoteAPIStatus""" - UserError = () - """:type : EvernoteAPIStatus""" - NotFoundError = () - """:type : EvernoteAPIStatus""" - MissingDataError = () - """:type : EvernoteAPIStatus""" - UnhandledError = () - """:type : EvernoteAPIStatus""" - GenericError = () - """:type : EvernoteAPIStatus""" - Unknown = 100 - """:type : EvernoteAPIStatus""" - - # def __new__(cls, *args, **kwargs): - # """:rtype : EvernoteAPIStatus""" - # return type(cls).__new__(*args, **kwargs) - - @property - def IsError(self): - return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value - - @property - def IsDelayableError(self): - return self.value == EvernoteAPIStatus.RateLimitError.value or self.value == EvernoteAPIStatus.SocketError.value - - @property - def IsSuccessful(self): - return EvernoteAPIStatus.Success.value >= self.value >= EvernoteAPIStatus.Manual.value - - @property - def IsSuccess(self): - return self == EvernoteAPIStatus.Success + Uninitialized = -100 + """:type : EvernoteAPIStatus""" + Initialized = -75 + """:type : EvernoteAPIStatus""" + UnableToFindStatus = -70 + """:type : EvernoteAPIStatus""" + InvalidStatus = -60 + """:type : EvernoteAPIStatus""" + Cancelled = -50 + """:type : EvernoteAPIStatus""" + Disabled = -25 + """:type : EvernoteAPIStatus""" + EmptyRequest = -10 + """:type : EvernoteAPIStatus""" + Manual = -5 + """:type : EvernoteAPIStatus""" + RequestSkipped = -4 + """:type : EvernoteAPIStatus""" + RequestQueued = -3 + """:type : EvernoteAPIStatus""" + ExceededLocalLimit = -2 + """:type : EvernoteAPIStatus""" + DelayedDueToRateLimit = -1 + """:type : EvernoteAPIStatus""" + Success = 0 + """:type : EvernoteAPIStatus""" + RateLimitError = () + """:type : EvernoteAPIStatus""" + SocketError = () + """:type : EvernoteAPIStatus""" + UserError = () + """:type : EvernoteAPIStatus""" + NotFoundError = () + """:type : EvernoteAPIStatus""" + MissingDataError = () + """:type : EvernoteAPIStatus""" + UnhandledError = () + """:type : EvernoteAPIStatus""" + GenericError = () + """:type : EvernoteAPIStatus""" + Unknown = 100 + """:type : EvernoteAPIStatus""" + + # def __new__(cls, *args, **kwargs): + # """:rtype : EvernoteAPIStatus""" + # return type(cls).__new__(*args, **kwargs) + + @property + def IsError(self): + return EvernoteAPIStatus.Unknown.value > self.value > EvernoteAPIStatus.Success.value + + @property + def IsDelayableError(self): + return self.value == EvernoteAPIStatus.RateLimitError.value or self.value == EvernoteAPIStatus.SocketError.value + + @property + def IsSuccessful(self): + return EvernoteAPIStatus.Success.value >= self.value >= EvernoteAPIStatus.Manual.value + + @property + def IsSuccess(self): + return self == EvernoteAPIStatus.Success class EvernoteImportType: - Add, UpdateInPlace, DeleteAndUpdate = range(3) + Add, UpdateInPlace, DeleteAndUpdate = range(3) class EvernoteNoteFetcherResult(object): - def __init__(self, note=None, status=None, source=-1): - """ + def __init__(self, note=None, status=None, source=-1): + """ - :type note: EvernoteNotePrototype.EvernoteNotePrototype - :type status: EvernoteAPIStatus - """ - if not status: status = EvernoteAPIStatus.Uninitialized - self.Note = note - self.Status = status - self.Source = source + :type note: EvernoteNotePrototype.EvernoteNotePrototype + :type status: EvernoteAPIStatus + """ + if not status: status = EvernoteAPIStatus.Uninitialized + self.Note = note + self.Status = status + self.Source = source class EvernoteNoteFetcherResults(object): - Status = EvernoteAPIStatus.Uninitialized - ImportType = EvernoteImportType.Add - Local = 0 - Notes = [] - Imported = 0 - Max = 0 - AlreadyUpToDate = 0 - - @property - def DownloadSuccess(self): - return self.Count == self.Max - - @property - def AnkiSuccess(self): - return self.Imported == self.Count - - @property - def TotalSuccess(self): - return self.DownloadSuccess and self.AnkiSuccess - - @property - def LocalDownloadsOccurred(self): - return self.Local > 0 - - @property - def Remote(self): - return self.Count - self.Local - - @property - def SummaryShort(self): - add_update_strs = ['New', "Added"] if self.ImportType == EvernoteImportType.Add else ['Existing', 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated'] - return "%d %s Notes Have Been %s" % (self.Imported, add_update_strs[0], add_update_strs[1]) - - @property - def SummaryLines(self): - if self.Max is 0: return [] - add_update_strs = ['New', "Added to"] if self.ImportType == EvernoteImportType.Add else ['Existing', "%s in" % ('Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated')] - add_update_strs[1] += " Anki" - - ## Evernote Status - if self.DownloadSuccess: - line = "All %3d" % self.Max - else: - line = "%3d of %3d" % (self.Count, self.Max) - lines = [line + " %s Evernote Metadata Results Were Successfully Downloaded%s." % ( - add_update_strs[0], (' And %s' % add_update_strs[1]) if self.AnkiSuccess else '')] - if self.Status.IsError: - lines.append("-An error occurred during download (%s)." % str(self.Status)) - - ## Local Calls - if self.LocalDownloadsOccurred: - lines.append( - "-%3d %s note%s unexpectedly found in the local db and did not require an API call." % (self.Local, add_update_strs[0], 's were' if self.Local > 1 else ' was')) - lines.append("-%3d %s note(s) required an API call" % (self.Remote, add_update_strs[0])) - if not self.ImportType == EvernoteImportType.Add and self.AlreadyUpToDate > 0: - lines.append( - "-%3d existing note%s already up-to-date with Evernote's servers, so %s not retrieved." % (self.AlreadyUpToDate, 's are' if self.Local > 1 else ' is', 'they were' if self.Local > 1 else 'it was')) - - ## Anki Status - if self.DownloadSuccess: - return lines - if self.AnkiSuccess: - line = "All %3d" % self.Imported - else: - line = "%3d of %3d" % (self.Imported, self.Count) - lines.append(line + " %s Downloaded Evernote Notes Have Been Successfully %s." % ( - add_update_strs[0], add_update_strs[1])) - - - - return lines - - @property - def Summary(self): - lines = self.SummaryLines - if len(lines) is 0: - return '' - return '<BR> - '.join(lines) - - @property - def Count(self): - return len(self.Notes) - - @property - def EvernoteFails(self): - return self.Max - self.Count - - @property - def AnkiFails(self): - return self.Count - self.Imported - - def __init__(self, status=None, local=None): - """ - :param status: - :type status : EvernoteAPIStatus - :param local: - :return: - """ - if not status: status = EvernoteAPIStatus.Uninitialized - if not local: local = 0 - self.Status = status - self.Local = local - self.Imported = 0 - self.Notes = [] - """ - :type : list[EvernoteNotePrototype.EvernoteNotePrototype] - """ - - def reportResult(self, result): - """ - :type result : EvernoteNoteFetcherResult - """ - self.Status = result.Status - if self.Status == EvernoteAPIStatus.Success: - self.Notes.append(result.Note) - if result.Source == 1: - self.Local += 1 + Status = EvernoteAPIStatus.Uninitialized + ImportType = EvernoteImportType.Add + Local = 0 + Notes = [] + Imported = 0 + Max = 0 + AlreadyUpToDate = 0 + + @property + def DownloadSuccess(self): + return self.Count == self.Max + + @property + def AnkiSuccess(self): + return self.Imported == self.Count + + @property + def TotalSuccess(self): + return self.DownloadSuccess and self.AnkiSuccess + + @property + def LocalDownloadsOccurred(self): + return self.Local > 0 + + @property + def Remote(self): + return self.Count - self.Local + + @property + def SummaryShort(self): + add_update_strs = ['New', "Added"] if self.ImportType == EvernoteImportType.Add else ['Existing', + 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated'] + return "%d %s Notes Have Been %s" % (self.Imported, add_update_strs[0], add_update_strs[1]) + + @property + def SummaryLines(self): + if self.Max is 0: return [] + add_update_strs = ['New', "Added to"] if self.ImportType == EvernoteImportType.Add else ['Existing', + "%s in" % ( + 'Updated In-Place' if self.ImportType == EvernoteImportType.UpdateInPlace else 'Deleted and Updated')] + add_update_strs[1] += " Anki" + + ## Evernote Status + if self.DownloadSuccess: + line = "All %3d" % self.Max + else: + line = "%3d of %3d" % (self.Count, self.Max) + lines = [line + " %s Evernote Metadata Results Were Successfully Downloaded%s." % ( + add_update_strs[0], (' And %s' % add_update_strs[1]) if self.AnkiSuccess else '')] + if self.Status.IsError: + lines.append("-An error occurred during download (%s)." % str(self.Status)) + + ## Local Calls + if self.LocalDownloadsOccurred: + lines.append( + "-%3d %s note%s unexpectedly found in the local db and did not require an API call." % ( + self.Local, add_update_strs[0], 's were' if self.Local > 1 else ' was')) + lines.append("-%3d %s note(s) required an API call" % (self.Remote, add_update_strs[0])) + if not self.ImportType == EvernoteImportType.Add and self.AlreadyUpToDate > 0: + lines.append( + "-%3d existing note%s already up-to-date with Evernote's servers, so %s not retrieved." % ( + self.AlreadyUpToDate, 's are' if self.Local > 1 else ' is', + 'they were' if self.Local > 1 else 'it was')) + + ## Anki Status + if self.DownloadSuccess: + return lines + if self.AnkiSuccess: + line = "All %3d" % self.Imported + else: + line = "%3d of %3d" % (self.Imported, self.Count) + lines.append(line + " %s Downloaded Evernote Notes Have Been Successfully %s." % ( + add_update_strs[0], add_update_strs[1])) + + return lines + + @property + def Summary(self): + lines = self.SummaryLines + if len(lines) is 0: + return '' + return '<BR> - '.join(lines) + + @property + def Count(self): + return len(self.Notes) + + @property + def EvernoteFails(self): + return self.Max - self.Count + + @property + def AnkiFails(self): + return self.Count - self.Imported + + def __init__(self, status=None, local=None): + """ + :param status: + :type status : EvernoteAPIStatus + :param local: + :return: + """ + if not status: status = EvernoteAPIStatus.Uninitialized + if not local: local = 0 + self.Status = status + self.Local = local + self.Imported = 0 + self.Notes = [] + """ + :type : list[EvernoteNotePrototype.EvernoteNotePrototype] + """ + + def reportResult(self, result): + """ + :type result : EvernoteNoteFetcherResult + """ + self.Status = result.Status + if self.Status == EvernoteAPIStatus.Success: + self.Notes.append(result.Note) + if result.Source == 1: + self.Local += 1 class EvernoteImportProgress: - Anki = None - """:type : anknotes.Anki.Anki""" - - class _GUIDs: - Local = None - - class Server: - All = None - New = None - - class Existing: - All = None - UpToDate = None - OutOfDate = None - - def loadNew(self, server_evernote_guids=None): - if server_evernote_guids: - self.Server.All = server_evernote_guids - if not self.Server.All: - return - setServer = set(self.Server.All) - self.Server.New = setServer - set(self.Local) - self.Server.Existing.All = setServer - set(self.Server.New) - - class Results: - Adding = None - """:type : EvernoteNoteFetcherResults""" - Updating = None - """:type : EvernoteNoteFetcherResults""" - - GUIDs = _GUIDs() - - @property - def Adding(self): - return len(self.GUIDs.Server.New) - - @property - def Updating(self): - return len(self.GUIDs.Server.Existing.OutOfDate) - - @property - def AlreadyUpToDate(self): - return len(self.GUIDs.Server.Existing.UpToDate) - - @property - def Success(self): - return self.Status == EvernoteAPIStatus.Success - - @property - def IsError(self): - return self.Status.IsError - - @property - def Status(self): - s1 = self.Results.Adding.Status - s2 = self.Results.Updating.Status if self.Results.Updating else EvernoteAPIStatus.Uninitialized - if s1 == EvernoteAPIStatus.RateLimitError or s2 == EvernoteAPIStatus.RateLimitError: - return EvernoteAPIStatus.RateLimitError - if s1 == EvernoteAPIStatus.SocketError or s2 == EvernoteAPIStatus.SocketError: - return EvernoteAPIStatus.SocketError - if s1.IsError: - return s1 - if s2.IsError: - return s2 - if s1.IsSuccessful and s2.IsSuccessful: - return EvernoteAPIStatus.Success - if s2 == EvernoteAPIStatus.Uninitialized: - return s1 - if s1 == EvernoteAPIStatus.Success: - return s2 - return s1 - - @property - def SummaryList(self): - return [ - "New Notes: %d" % self.Adding, - "Out-Of-Date Notes: %d" % self.Updating, - "Up-To-Date Notes: %d" % self.AlreadyUpToDate - ] - - @property - def Summary(self): return JoinList(self.SummaryList, ' | ', ANKNOTES.FORMATTING.PROGRESS_SUMMARY_PAD) - - def loadAlreadyUpdated(self, db_guids): - self.GUIDs.Server.Existing.UpToDate = db_guids - self.GUIDs.Server.Existing.OutOfDate = set(self.GUIDs.Server.Existing.All) - set( - self.GUIDs.Server.Existing.UpToDate) - - def processUpdateInPlaceResults(self, results): - return self.processResults(results, EvernoteImportType.UpdateInPlace) - - def processDeleteAndUpdateResults(self, results): - return self.processResults(results, EvernoteImportType.DeleteAndUpdate) - - @property - def ResultsSummaryShort(self): - line = self.Results.Adding.SummaryShort - if self.Results.Adding.Status.IsError: - line += " to Anki. Skipping update due to an error (%s)" % self.Results.Adding.Status - elif not self.Results.Updating: - line += " to Anki. Updating is disabled" - else: - line += " and " + self.Results.Updating.SummaryShort - return line - - @property - def ResultsSummaryLines(self): - lines = [self.ResultsSummaryShort] + self.Results.Adding.SummaryLines - if self.Results.Updating: - lines += self.Results.Updating.SummaryLines - return lines - - @property - def APICallCount(self): - return self.Results.Adding.Remote + self.Results.Updating.Remote if self.Results.Updating else 0 - - def processResults(self, results, importType=None): - """ - :type results : EvernoteNoteFetcherResults - :type importType : EvernoteImportType - """ - if not importType: - importType = EvernoteImportType.Add - results.ImportType = importType - if importType == EvernoteImportType.Add: - results.Max = self.Adding - results.AlreadyUpToDate = 0 - self.Results.Adding = results - else: - results.Max = self.Updating - results.AlreadyUpToDate = self.AlreadyUpToDate - self.Results.Updating = results - - def setup(self, anki_note_ids=None): - if not anki_note_ids: - anki_note_ids = self.Anki.get_anknotes_note_ids() - self.GUIDs.Local = self.Anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) - - def __init__(self, anki=None, metadataProgress=None, server_evernote_guids=None, anki_note_ids=None): - """ - :param anki: Anknotes Main Anki Instance - :type anki: anknotes.Anki.Anki - :type metadataProgress: EvernoteMetadataProgress - :return: - """ - if not anki: - return - self.Anki = anki - self.setup(anki_note_ids) - if metadataProgress: - server_evernote_guids = metadataProgress.Guids - if server_evernote_guids: - self.GUIDs.loadNew(server_evernote_guids) - self.Results.Adding = EvernoteNoteFetcherResults() - self.Results.Updating = EvernoteNoteFetcherResults() + Anki = None + """:type : anknotes.Anki.Anki""" + + class _GUIDs: + Local = None + + class Server: + All = None + New = None + + class Existing: + All = None + UpToDate = None + OutOfDate = None + + def loadNew(self, server_evernote_guids=None): + if server_evernote_guids: + self.Server.All = server_evernote_guids + if not self.Server.All: + return + setServer = set(self.Server.All) + self.Server.New = setServer - set(self.Local) + self.Server.Existing.All = setServer - set(self.Server.New) + + class Results: + Adding = None + """:type : EvernoteNoteFetcherResults""" + Updating = None + """:type : EvernoteNoteFetcherResults""" + + GUIDs = _GUIDs() + + @property + def Adding(self): + if not self.GUIDs.Server.New: + return 0 + return len(self.GUIDs.Server.New) + + @property + def Updating(self): + if not self.GUIDs.Server.Existing.OutOfDate: + return 0 + return len(self.GUIDs.Server.Existing.OutOfDate) + + @property + def AlreadyUpToDate(self): + if not self.GUIDs.Server.Existing.UpToDate: + return 0 + return len(self.GUIDs.Server.Existing.UpToDate) + + @property + def Success(self): + return self.Status == EvernoteAPIStatus.Success + + @property + def IsError(self): + return self.Status.IsError + + @property + def Status(self): + s1 = self.Results.Adding.Status + s2 = self.Results.Updating.Status if self.Results.Updating else EvernoteAPIStatus.Uninitialized + if s1 == EvernoteAPIStatus.RateLimitError or s2 == EvernoteAPIStatus.RateLimitError: + return EvernoteAPIStatus.RateLimitError + if s1 == EvernoteAPIStatus.SocketError or s2 == EvernoteAPIStatus.SocketError: + return EvernoteAPIStatus.SocketError + if s1.IsError: + return s1 + if s2.IsError: + return s2 + if s1.IsSuccessful and s2.IsSuccessful: + return EvernoteAPIStatus.Success + if s2 == EvernoteAPIStatus.Uninitialized: + return s1 + if s1 == EvernoteAPIStatus.Success: + return s2 + return s1 + + @property + def SummaryList(self): + return [ + "New Notes: %d" % self.Adding, + "Out-Of-Date Notes: %d" % self.Updating, + "Up-To-Date Notes: %d" % self.AlreadyUpToDate + ] + + @property + def Summary(self): + return JoinList(self.SummaryList, ' | ', ANKNOTES.FORMATTING.PROGRESS_SUMMARY_PAD) + + def loadAlreadyUpdated(self, db_guids): + if not db_guids: + self.GUIDs.Server.Existing.UpToDate = None + self.GUIDs.Server.Existing.OutOfDate = None + return + self.GUIDs.Server.Existing.UpToDate = db_guids + self.GUIDs.Server.Existing.OutOfDate = set(self.GUIDs.Server.Existing.All) - set( + self.GUIDs.Server.Existing.UpToDate) + + def processUpdateInPlaceResults(self, results): + return self.processResults(results, EvernoteImportType.UpdateInPlace) + + def processDeleteAndUpdateResults(self, results): + return self.processResults(results, EvernoteImportType.DeleteAndUpdate) + + @property + def ResultsSummaryShort(self): + line = self.Results.Adding.SummaryShort + if self.Results.Adding.Status.IsError: + line += " to Anki. Skipping update due to an error (%s)" % self.Results.Adding.Status + elif not self.Results.Updating: + line += " to Anki. Updating is disabled" + else: + line += " and " + self.Results.Updating.SummaryShort + return line + + @property + def ResultsSummaryLines(self): + lines = [self.ResultsSummaryShort] + self.Results.Adding.SummaryLines + if self.Results.Updating: + lines += self.Results.Updating.SummaryLines + return lines + + @property + def APICallCount(self): + return self.Results.Adding.Remote + self.Results.Updating.Remote if self.Results.Updating else 0 + + def processResults(self, results, importType=None): + """ + :type results : EvernoteNoteFetcherResults + :type importType : EvernoteImportType + """ + if not importType: + importType = EvernoteImportType.Add + results.ImportType = importType + if importType == EvernoteImportType.Add: + results.Max = self.Adding + results.AlreadyUpToDate = 0 + self.Results.Adding = results + else: + results.Max = self.Updating + results.AlreadyUpToDate = self.AlreadyUpToDate + self.Results.Updating = results + + def setup(self, anki_note_ids=None): + if not anki_note_ids: + anki_note_ids = self.Anki.get_anknotes_note_ids() + self.GUIDs.Local = self.Anki.get_evernote_guids_from_anki_note_ids(anki_note_ids) + + def __init__(self, anki=None, metadataProgress=None, server_evernote_guids=None, anki_note_ids=None): + """ + :param anki: Anknotes Main Anki Instance + :type anki: anknotes.Anki.Anki + :type metadataProgress: EvernoteMetadataProgress + :return: + """ + if not anki: + return + self.Anki = anki + self.setup(anki_note_ids) + if metadataProgress: + server_evernote_guids = metadataProgress.Guids + if server_evernote_guids: + self.GUIDs.loadNew(server_evernote_guids) + self.Results.Adding = EvernoteNoteFetcherResults() + self.Results.Updating = EvernoteNoteFetcherResults() class EvernoteMetadataProgress: - Page = Total = Current = UpdateCount = -1 - Status = EvernoteAPIStatus.Uninitialized - Guids = [] - NotesMetadata = {} - """ - :type: dict[str, anknotes.evernote.edam.notestore.ttypes.NoteMetadata] - """ - - @property - def IsFinished(self): - return self.Remaining <= 0 - - @property - def SummaryList(self): - return [["Total Notes: %d" % self.Total, - "Total Pages: %d" % self.TotalPages, - "Returned Notes: %d" % self.Current, - "Result Range: %d-%d" % (self.Offset, self.Completed) - ], - ["Remaining Notes: %d" % self.Remaining, - "Remaining Pages: %d" % self.RemainingPages, - "Update Count: %d" % self.UpdateCount]] - - @property - def Summary(self): return JoinList(self.SummaryList, ['\n', ' | '], ANKNOTES.FORMATTING.PROGRESS_SUMMARY_PAD) - - @property - def QueryLimit(self): return EVERNOTE.IMPORT.QUERY_LIMIT - - @property - def Offset(self): return (self.Page - 1) * self.QueryLimit - - @property - def TotalPages(self): - if self.Total is -1: return -1 - p = float(self.Total) / self.QueryLimit - return int(p) + (1 if p > int(p) else 0) - - @property - def RemainingPages(self): return self.TotalPages - self.Page - - @property - def Completed(self): return self.Current + self.Offset - - @property - def Remaining(self): return self.Total - self.Completed - - def __init__(self, page=1): - self.Page = int(page) - - def loadResults(self, result): - """ - :param result: Result Returned by Evernote API Call to getNoteMetadata - :type result: anknotes.evernote.edam.notestore.ttypes.NotesMetadataList - :return: - """ - self.Total = int(result.totalNotes) - self.Current = len(result.notes) - self.UpdateCount = result.updateCount - self.Status = EvernoteAPIStatus.Success - self.Guids = [] - self.NotesMetadata = {} - for note in result.notes: - # assert isinstance(note, NoteMetadata) - self.Guids.append(note.guid) - self.NotesMetadata[note.guid] = note + Page = Total = Current = UpdateCount = -1 + Status = EvernoteAPIStatus.Uninitialized + Guids = [] + NotesMetadata = {} + """ + :type: dict[str, anknotes.evernote.edam.notestore.ttypes.NoteMetadata] + """ + + @property + def IsFinished(self): + return self.Remaining <= 0 + + @property + def SummaryList(self): + return [["Total Notes: %d" % self.Total, + "Total Pages: %d" % self.TotalPages, + "Returned Notes: %d" % self.Current, + "Result Range: %d-%d" % (self.Offset, self.Completed) + ], + ["Remaining Notes: %d" % self.Remaining, + "Remaining Pages: %d" % self.RemainingPages, + "Update Count: %d" % self.UpdateCount]] + + @property + def Summary(self): + return JoinList(self.SummaryList, ['\n', ' | '], ANKNOTES.FORMATTING.PROGRESS_SUMMARY_PAD) + + @property + def QueryLimit(self): + return EVERNOTE.IMPORT.QUERY_LIMIT + + @property + def Offset(self): + return (self.Page - 1) * self.QueryLimit + + @property + def TotalPages(self): + if self.Total is -1: return -1 + p = float(self.Total) / self.QueryLimit + return int(p) + (1 if p > int(p) else 0) + + @property + def RemainingPages(self): + return self.TotalPages - self.Page + + @property + def Completed(self): + return self.Current + self.Offset + + @property + def Remaining(self): + return self.Total - self.Completed + + def __init__(self, page=1): + self.Page = int(page) + + def loadResults(self, result): + """ + :param result: Result Returned by Evernote API Call to getNoteMetadata + :type result: anknotes.evernote.edam.notestore.ttypes.NotesMetadataList + :return: + """ + self.Total = int(result.totalNotes) + self.Current = len(result.notes) + self.UpdateCount = result.updateCount + self.Status = EvernoteAPIStatus.Success + self.Guids = [] + self.NotesMetadata = {} + for note in result.notes: + # assert isinstance(note, NoteMetadata) + self.Guids.append(note.guid) + self.NotesMetadata[note.guid] = note diff --git a/anknotes/toc.py b/anknotes/toc.py index b96be48..0a5d76c 100644 --- a/anknotes/toc.py +++ b/anknotes/toc.py @@ -1,335 +1,351 @@ # -*- coding: utf-8 -*- try: - from pysqlite2 import dbapi2 as sqlite + from pysqlite2 import dbapi2 as sqlite except ImportError: - from sqlite3 import dbapi2 as sqlite + from sqlite3 import dbapi2 as sqlite from anknotes.constants import * from anknotes.html import generate_evernote_link, generate_evernote_span from anknotes.logging import log_dump from anknotes.EvernoteNoteTitle import EvernoteNoteTitle, generateTOCTitle from anknotes.EvernoteNotePrototype import EvernoteNotePrototype + def TOCNamePriority(title): - for index, value in enumerate( - ['Summary', 'Definition', 'Classification', 'Types', 'Presentation', 'Organ Involvement', 'Age of Onset', - 'Si/Sx', 'Sx', 'Sign', 'MCC\'s', 'MCC', 'Inheritance', 'Incidence', 'Prognosis', 'Mechanism', 'MOA', - 'Pathophysiology', 'Indications', 'Examples', 'Cause', 'Causes', 'Causative Organisms', 'Risk Factors', - 'Complication', 'Complications', 'Side Effects', 'Drug S/E', 'Associated Conditions', 'A/w', 'Dx', - 'Physical Exam', 'Labs', 'Hemodynamic Parameters', 'Lab Findings', 'Imaging', 'Screening Test', - 'Confirmatory Test']): - if title == value: return -1, index - for index, value in enumerate(['Management', 'Work Up', 'Tx']): - if title == value: return 1, index - return 0, 0 + for index, value in enumerate( + ['Summary', 'Definition', 'Classification', 'Types', 'Presentation', 'Organ Involvement', 'Age of Onset', + 'Si/Sx', 'Sx', 'Sign', 'MCC\'s', 'MCC', 'Inheritance', 'Incidence', 'Prognosis', 'Mechanism', 'MOA', + 'Pathophysiology', 'Indications', 'Examples', 'Cause', 'Causes', 'Causative Organisms', 'Risk Factors', + 'Complication', 'Complications', 'Side Effects', 'Drug S/E', 'Associated Conditions', 'A/w', 'Dx', + 'Physical Exam', 'Labs', 'Hemodynamic Parameters', 'Lab Findings', 'Imaging', 'Screening Test', + 'Confirmatory Test']): + if title == value: return -1, index + for index, value in enumerate(['Management', 'Work Up', 'Tx']): + if title == value: return 1, index + return 0, 0 def TOCNameSort(title1, title2): - priority1 = TOCNamePriority(title1) - priority2 = TOCNamePriority(title2) - # Lower value for item 1 = item 1 placed BEFORE item 2 - if priority1[0] != priority2[0]: return priority1[0] - priority2[0] - if priority1[1] != priority2[1]: return priority1[1] - priority2[1] - return cmp(title1, title2) + priority1 = TOCNamePriority(title1) + priority2 = TOCNamePriority(title2) + # Lower value for item 1 = item 1 placed BEFORE item 2 + if priority1[0] != priority2[0]: return priority1[0] - priority2[0] + if priority1[1] != priority2[1]: return priority1[1] - priority2[1] + return cmp(title1, title2) def TOCSort(hash1, hash2): - lvl1 = hash1.Level - lvl2 = hash2.Level - names1 = hash1.TitleParts - names2 = hash2.TitleParts - for i in range(0, min(lvl1, lvl2)): - name1 = names1[i] - name2 = names2[i] - if name1 != name2: return TOCNameSort(name1, name2) - # Lower value for item 1 = item 1 placed BEFORE item 2 - return lvl1 - lvl2 + lvl1 = hash1.Level + lvl2 = hash2.Level + names1 = hash1.TitleParts + names2 = hash2.TitleParts + for i in range(0, min(lvl1, lvl2)): + name1 = names1[i] + name2 = names2[i] + if name1 != name2: return TOCNameSort(name1, name2) + # Lower value for item 1 = item 1 placed BEFORE item 2 + return lvl1 - lvl2 class TOCHierarchyClass: - Title = None - """:type : EvernoteNoteTitle""" - Note = None - """:type : EvernoteNotePrototype.EvernoteNotePrototype""" - Outline = None - """:type : TOCHierarchyClass""" - Number = 1 - Children = [] - """:type : list[TOCHierarchyClass]""" - Parent = None - """:type : TOCHierarchyClass""" - __isSorted__ = False - - @staticmethod - def TOCItemSort(tocHierarchy1, tocHierarchy2): - lvl1 = tocHierarchy1.Level - lvl2 = tocHierarchy2.Level - names1 = tocHierarchy1.TitleParts - names2 = tocHierarchy2.TitleParts - for i in range(0, min(lvl1, lvl2)): - name1 = names1[i] - name2 = names2[i] - if name1 != name2: return TOCNameSort(name1, name2) - # Lower value for item 1 = item 1 placed BEFORE item 2 - return lvl1 - lvl2 - - @property - def IsOutline(self): - if not self.Note: return False - return TAGS.OUTLINE in self.Note.Tags - - def sortIfNeeded(self): - if self.__isSorted__: return - self.sortChildren() - - @property - def FullTitle(self): return self.Title.FullTitle if self.Title else "" - - @property - def Level(self): - return self.Title.Level - - @property - def ChildrenCount(self): - return len(self.Children) - - @property - def TitleParts(self): - return self.Title.TitleParts - - def addNote(self, note): - tocHierarchy = TOCHierarchyClass(note=note) - self.addHierarchy(tocHierarchy) - - def getChildIndex(self, tocChildHierarchy): - if not tocChildHierarchy in self.Children: return -1 - self.sortIfNeeded() - return self.Children.index(tocChildHierarchy) - - @property - def ListPrefix(self): - index = self.Index - isSingleItem = self.IsSingleItem - if isSingleItem is 0: return "" - if isSingleItem is 1: return "*" - return str(index) + "." - - @property - def IsSingleItem(self): - index = self.Index - if index is 0: return 0 - if index is 1 and len(self.Parent.Children) is 1: - return 1 - return -1 - - @property - def Index(self): - if not self.Parent: return 0 - return self.Parent.getChildIndex(self) + 1 - - def addTitle(self, title): - self.addHierarchy(TOCHierarchyClass(title)) - - def addHierarchy(self, tocHierarchy): - tocNewTitle = tocHierarchy.Title - tocNewLevel = tocNewTitle.Level - selfLevel = self.Title.Level - tocTestBase = tocHierarchy.FullTitle.replace(self.FullTitle, '') - if tocTestBase[:2] == ': ': - tocTestBase = tocTestBase[2:] - - print " \nAdd Hierarchy: %-70s --> %-40s\n-------------------------------------" % ( - self.FullTitle, tocTestBase) - - if selfLevel > tocHierarchy.Title.Level: - print "New Title Level is Below current level" - return False - - selfTOCTitle = self.Title.TOCTitle - tocSelfSibling = tocNewTitle.Parents(self.Title.Level) - - if tocSelfSibling.TOCTitle != selfTOCTitle: - print "New Title doesn't match current path" - return False - - if tocNewLevel is self.Title.Level: - if tocHierarchy.IsOutline: - tocHierarchy.Parent = self - self.Outline = tocHierarchy - print "SUCCESS: Outline added" - return True - print "New Title Level is current level, but New Title is not Outline" - return False - - tocNewSelfChild = tocNewTitle.Parents(self.Title.Level + 1) - tocNewSelfChildTOCName = tocNewSelfChild.TOCName - isDirectChild = (tocHierarchy.Level == self.Level + 1) - if isDirectChild: - tocNewChildNamesTitle = "N/A" - print "New Title is a direct child of the current title" - else: - tocNewChildNamesTitle = tocHierarchy.Title.Names(self.Title.Level + 1).FullTitle - print "New Title is a Grandchild or deeper of the current title " - - for tocChild in self.Children: - assert (isinstance(tocChild, TOCHierarchyClass)) - if tocChild.Title.TOCName == tocNewSelfChildTOCName: - print "%-60s Child %-20s Match Succeeded for %s." % ( - self.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) - success = tocChild.addHierarchy(tocHierarchy) - if success: - return True - print "%-60s Child %-20s Match Succeeded for %s: However, unable to add to matched child" % ( - self.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) - print "%-60s Child %-20s Search failed for %s" % ( - self.FullTitle + ':', tocNewSelfChild.Name, tocNewChildNamesTitle) - - newChild = tocHierarchy if isDirectChild else TOCHierarchyClass(tocNewSelfChild) - newChild.parent = self - if isDirectChild: - print "%-60s Child %-20s Created Direct Child for %s." % ( - self.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) - success = True - else: - print "%-60s Child %-20s Created Title-Only Child for %-40ss." % ( - self.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) - success = newChild.addHierarchy(tocHierarchy) - print "%-60s Child %-20s Created Title-Only Child for %-40s: Match %s." % ( - self.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle, - "succeeded" if success else "failed") - self.__isSorted__ = False - self.Children.append(newChild) - - print "%-60s Child %-20s Appended Child for %s. Operation was an overall %s." % ( - self.FullTitle + ':', newChild.Title.Name + ':', tocNewChildNamesTitle, - "success" if success else "failure") - return success - - def sortChildren(self): - self.Children = sorted(self.Children, self.TOCItemSort) - self.__isSorted__ = True - - def __strsingle__(self, fullTitle=False): - selfTitleStr = self.FullTitle - selfNameStr = self.Title.Name - selfLevel = self.Title.Level - selfDepth = self.Title.Depth - selfListPrefix = self.ListPrefix - strr = '' - if selfLevel == 1: - strr += ' [%d] ' % len(self.Children) - else: - if len(self.Children): - strr += ' [%d:%2d] ' % (selfDepth, len(self.Children)) - else: - strr += ' [%d] ' % selfDepth - strr += ' ' * (selfDepth * 3) - strr += ' %s ' % selfListPrefix - - strr += '%-60s %s' % (selfTitleStr if fullTitle else selfNameStr, '' if self.Note else '(No Note)') - return strr - - def __str__(self, fullTitle=True, fullChildrenTitles=False): - self.sortIfNeeded() - lst = [self.__strsingle__(fullTitle)] - for child in self.Children: - lst.append(child.__str__(fullChildrenTitles, fullChildrenTitles)) - return '\n'.join(lst) - - def GetOrderedListItem(self, title=None): - if not title: title = self.Title.Name - selfTitleStr = title - selfLevel = self.Title.Level - selfDepth = self.Title.Depth - if selfLevel == 1: - guid = 'guid-pending' - if self.Note: guid = self.Note.Guid - link = generate_evernote_link(guid, generateTOCTitle(selfTitleStr), 'TOC') - if self.Outline: - link += ' ' + generate_evernote_link(self.Outline.Note.Guid, - '(<span style="color: rgb(255, 255, 255);">O</span>)', 'Outline', - escape=False) - return link - if self.Note: - return self.Note.generateLevelLink(selfDepth) - else: - return generate_evernote_span(selfTitleStr, 'Levels', selfDepth) - - def GetOrderedList(self, title=None): - self.sortIfNeeded() - lst = [] - header = (self.GetOrderedListItem(title)) - if self.ChildrenCount > 0: - for child in self.Children: - lst.append(child.GetOrderedList()) - childHTML = '\n'.join(lst) - else: - childHTML = '' - if childHTML: - tag = 'ol' if self.ChildrenCount > 1 else 'ul' - base = '<%s>\r\n%s\r\n</%s>\r\n' - # base = base.encode('utf8') - # tag = tag.encode('utf8') - # childHTML = childHTML.encode('utf8') - childHTML = base % (tag, childHTML, tag) - - if self.Level is 1: - base = '<div> %s </div>\r\n %s \r\n' - # base = base.encode('utf8') - # childHTML = childHTML.encode('utf8') - # header = header.encode('utf8') - base = base % (header, childHTML) - return base - base = '<li> %s \r\n %s \r\n</li> \r\n' - # base = base.encode('utf8') - # header = header.encode('utf8') - # childHTML = childHTML.encode('utf8') - base = base % (header, childHTML) - return base - - def __reprsingle__(self, fullTitle=True): - selfTitleStr = self.FullTitle - selfNameStr = self.Title.Name - # selfLevel = self.title.Level - # selfDepth = self.title.Depth - selfListPrefix = self.ListPrefix - strr = "<%s:%s[%d] %s%s>" % ( - self.__class__.__name__, selfListPrefix, len(self.Children), selfTitleStr if fullTitle else selfNameStr, - '' if self.Note else ' *') - return strr - - def __repr__(self, fullTitle=True, fullChildrenTitles=False): - self.sortIfNeeded() - lst = [self.__reprsingle__(fullTitle)] - for child in self.Children: - lst.append(child.__repr__(fullChildrenTitles, fullChildrenTitles)) - return '\n'.join(lst) - - def __init__(self, title=None, note=None, number=1): - """ - :type title: EvernoteNoteTitle - :type note: EvernoteNotePrototype.EvernoteNotePrototype - """ - assert note or title - self.Outline = None - if note: - if (isinstance(note, sqlite.Row)): - note = EvernoteNotePrototype(db_note=note) - - self.Note = note - self.Title = EvernoteNoteTitle(note) - else: - self.Title = EvernoteNoteTitle(title) - self.Note = None - self.Number = number - self.Children = [] - self.__isSorted__ = False - - # - # tocTest = TOCHierarchyClass("My Root Title") - # tocTest.addTitle("My Root Title: Somebody") - # tocTest.addTitle("My Root Title: Somebody: Else") - # tocTest.addTitle("My Root Title: Someone") - # tocTest.addTitle("My Root Title: Someone: Else") - # tocTest.addTitle("My Root Title: Someone: Else: Entirely") - # tocTest.addTitle("My Root Title: Z This: HasNo: Direct Parent") - # pass + Title = None + """:type : EvernoteNoteTitle""" + Note = None + """:type : EvernoteNotePrototype.EvernoteNotePrototype""" + Outline = None + """:type : TOCHierarchyClass""" + Number = 1 + Children = [] + """:type : list[TOCHierarchyClass]""" + Parent = None + """:type : TOCHierarchyClass""" + __isSorted__ = False + + @staticmethod + def TOCItemSort(tocHierarchy1, tocHierarchy2): + lvl1 = tocHierarchy1.Level + lvl2 = tocHierarchy2.Level + names1 = tocHierarchy1.TitleParts + names2 = tocHierarchy2.TitleParts + for i in range(0, min(lvl1, lvl2)): + name1 = names1[i] + name2 = names2[i] + if name1 != name2: return TOCNameSort(name1, name2) + # Lower value for item 1 = item 1 placed BEFORE item 2 + return lvl1 - lvl2 + + @property + def IsOutline(self): + if not self.Note: return False + return TAGS.OUTLINE in self.Note.Tags + + def sortIfNeeded(self): + if self.__isSorted__: return + self.sortChildren() + + @property + def FullTitle(self): + return self.Title.FullTitle if self.Title else "" + + @property + def Level(self): + return self.Title.Level + + @property + def ChildrenCount(self): + return len(self.Children) + + @property + def TitleParts(self): + return self.Title.TitleParts + + def addNote(self, note): + tocHierarchy = TOCHierarchyClass(note=note) + self.addHierarchy(tocHierarchy) + + def getChildIndex(self, tocChildHierarchy): + if not tocChildHierarchy in self.Children: return -1 + self.sortIfNeeded() + return self.Children.index(tocChildHierarchy) + + @property + def ListPrefix(self): + index = self.Index + isSingleItem = self.IsSingleItem + if isSingleItem is 0: return "" + if isSingleItem is 1: return "*" + return str(index) + "." + + @property + def IsSingleItem(self): + index = self.Index + if index is 0: return 0 + if index is 1 and len(self.Parent.Children) is 1: + return 1 + return -1 + + @property + def Index(self): + if not self.Parent: return 0 + return self.Parent.getChildIndex(self) + 1 + + def addTitle(self, title): + self.addHierarchy(TOCHierarchyClass(title)) + + def addHierarchy(self, tocHierarchy): + tocNewTitle = tocHierarchy.Title + tocNewLevel = tocNewTitle.Level + selfLevel = self.Title.Level + tocTestBase = tocHierarchy.FullTitle.replace(self.FullTitle, '') + if tocTestBase[:2] == ': ': + tocTestBase = tocTestBase[2:] + + print + " \nAdd Hierarchy: %-70s --> %-40s\n-------------------------------------" % ( + self.FullTitle, tocTestBase) + + if selfLevel > tocHierarchy.Title.Level: + print + "New Title Level is Below current level" + return False + + selfTOCTitle = self.Title.TOCTitle + tocSelfSibling = tocNewTitle.Parents(self.Title.Level) + + if tocSelfSibling.TOCTitle != selfTOCTitle: + print + "New Title doesn't match current path" + return False + + if tocNewLevel is self.Title.Level: + if tocHierarchy.IsOutline: + tocHierarchy.Parent = self + self.Outline = tocHierarchy + print + "SUCCESS: Outline added" + return True + print + "New Title Level is current level, but New Title is not Outline" + return False + + tocNewSelfChild = tocNewTitle.Parents(self.Title.Level + 1) + tocNewSelfChildTOCName = tocNewSelfChild.TOCName + isDirectChild = (tocHierarchy.Level == self.Level + 1) + if isDirectChild: + tocNewChildNamesTitle = "N/A" + print + "New Title is a direct child of the current title" + else: + tocNewChildNamesTitle = tocHierarchy.Title.Names(self.Title.Level + 1).FullTitle + print + "New Title is a Grandchild or deeper of the current title " + + for tocChild in self.Children: + assert (isinstance(tocChild, TOCHierarchyClass)) + if tocChild.Title.TOCName == tocNewSelfChildTOCName: + print + "%-60s Child %-20s Match Succeeded for %s." % ( + self.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + success = tocChild.addHierarchy(tocHierarchy) + if success: + return True + print + "%-60s Child %-20s Match Succeeded for %s: However, unable to add to matched child" % ( + self.FullTitle + ':', tocChild.Title.Name + ':', tocNewChildNamesTitle) + print + "%-60s Child %-20s Search failed for %s" % ( + self.FullTitle + ':', tocNewSelfChild.Name, tocNewChildNamesTitle) + + newChild = tocHierarchy if isDirectChild else TOCHierarchyClass(tocNewSelfChild) + newChild.parent = self + if isDirectChild: + print + "%-60s Child %-20s Created Direct Child for %s." % ( + self.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + success = True + else: + print + "%-60s Child %-20s Created Title-Only Child for %-40ss." % ( + self.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle) + success = newChild.addHierarchy(tocHierarchy) + print + "%-60s Child %-20s Created Title-Only Child for %-40s: Match %s." % ( + self.FullTitle + ':', newChild.Title.Name, tocNewChildNamesTitle, + "succeeded" if success else "failed") + self.__isSorted__ = False + self.Children.append(newChild) + + print + "%-60s Child %-20s Appended Child for %s. Operation was an overall %s." % ( + self.FullTitle + ':', newChild.Title.Name + ':', tocNewChildNamesTitle, + "success" if success else "failure") + return success + + def sortChildren(self): + self.Children = sorted(self.Children, self.TOCItemSort) + self.__isSorted__ = True + + def __strsingle__(self, fullTitle=False): + selfTitleStr = self.FullTitle + selfNameStr = self.Title.Name + selfLevel = self.Title.Level + selfDepth = self.Title.Depth + selfListPrefix = self.ListPrefix + strr = '' + if selfLevel == 1: + strr += ' [%d] ' % len(self.Children) + else: + if len(self.Children): + strr += ' [%d:%2d] ' % (selfDepth, len(self.Children)) + else: + strr += ' [%d] ' % selfDepth + strr += ' ' * (selfDepth * 3) + strr += ' %s ' % selfListPrefix + + strr += '%-60s %s' % (selfTitleStr if fullTitle else selfNameStr, '' if self.Note else '(No Note)') + return strr + + def __str__(self, fullTitle=True, fullChildrenTitles=False): + self.sortIfNeeded() + lst = [self.__strsingle__(fullTitle)] + for child in self.Children: + lst.append(child.__str__(fullChildrenTitles, fullChildrenTitles)) + return '\n'.join(lst) + + def GetOrderedListItem(self, title=None): + if not title: title = self.Title.Name + selfTitleStr = title + selfLevel = self.Title.Level + selfDepth = self.Title.Depth + if selfLevel == 1: + guid = 'guid-pending' + if self.Note: guid = self.Note.Guid + link = generate_evernote_link(guid, generateTOCTitle(selfTitleStr), 'TOC') + if self.Outline: + link += ' ' + generate_evernote_link(self.Outline.Note.Guid, + '(<span style="color: rgb(255, 255, 255);">O</span>)', 'Outline', + escape=False) + return link + if self.Note: + return self.Note.generateLevelLink(selfDepth) + else: + return generate_evernote_span(selfTitleStr, 'Levels', selfDepth) + + def GetOrderedList(self, title=None): + self.sortIfNeeded() + lst = [] + header = (self.GetOrderedListItem(title)) + if self.ChildrenCount > 0: + for child in self.Children: + lst.append(child.GetOrderedList()) + childHTML = '\n'.join(lst) + else: + childHTML = '' + if childHTML: + tag = 'ol' if self.ChildrenCount > 1 else 'ul' + base = '<%s>\r\n%s\r\n</%s>\r\n' + # base = base.encode('utf8') + # tag = tag.encode('utf8') + # childHTML = childHTML.encode('utf8') + childHTML = base % (tag, childHTML, tag) + + if self.Level is 1: + base = '<div> %s </div>\r\n %s \r\n' + # base = base.encode('utf8') + # childHTML = childHTML.encode('utf8') + # header = header.encode('utf8') + base = base % (header, childHTML) + return base + base = '<li> %s \r\n %s \r\n</li> \r\n' + # base = base.encode('utf8') + # header = header.encode('utf8') + # childHTML = childHTML.encode('utf8') + base = base % (header, childHTML) + return base + + def __reprsingle__(self, fullTitle=True): + selfTitleStr = self.FullTitle + selfNameStr = self.Title.Name + # selfLevel = self.title.Level + # selfDepth = self.title.Depth + selfListPrefix = self.ListPrefix + strr = "<%s:%s[%d] %s%s>" % ( + self.__class__.__name__, selfListPrefix, len(self.Children), selfTitleStr if fullTitle else selfNameStr, + '' if self.Note else ' *') + return strr + + def __repr__(self, fullTitle=True, fullChildrenTitles=False): + self.sortIfNeeded() + lst = [self.__reprsingle__(fullTitle)] + for child in self.Children: + lst.append(child.__repr__(fullChildrenTitles, fullChildrenTitles)) + return '\n'.join(lst) + + def __init__(self, title=None, note=None, number=1): + """ + :type title: EvernoteNoteTitle + :type note: EvernoteNotePrototype.EvernoteNotePrototype + """ + assert note or title + self.Outline = None + if note: + if (isinstance(note, sqlite.Row)): + note = EvernoteNotePrototype(db_note=note) + + self.Note = note + self.Title = EvernoteNoteTitle(note) + else: + self.Title = EvernoteNoteTitle(title) + self.Note = None + self.Number = number + self.Children = [] + self.__isSorted__ = False + + # + # tocTest = TOCHierarchyClass("My Root Title") + # tocTest.addTitle("My Root Title: Somebody") + # tocTest.addTitle("My Root Title: Somebody: Else") + # tocTest.addTitle("My Root Title: Someone") + # tocTest.addTitle("My Root Title: Someone: Else") + # tocTest.addTitle("My Root Title: Someone: Else: Entirely") + # tocTest.addTitle("My Root Title: Z This: HasNo: Direct Parent") + # pass diff --git a/anknotes/version.py b/anknotes/version.py index 2bdceaa..d9a0804 100644 --- a/anknotes/version.py +++ b/anknotes/version.py @@ -37,18 +37,18 @@ class Version: seem to be the same for all version numbering classes. """ - def __init__ (self, vstring=None): + def __init__(self, vstring=None): if vstring: self.parse(vstring) - def __repr__ (self): + def __repr__(self): return "%s ('%s')" % (self.__class__.__name__, str(self)) # Interface for version-number classes -- must be implemented # by the following classes (the concrete ones -- Version should # be treated as an abstract class). -# __init__ (string) - create and take same action as 'parse' +# __init__ (string) - create and take same action as 'parse' # (string parameter is optional) # parse (string) - convert a string representation to whatever # internal representation is appropriate for @@ -62,8 +62,7 @@ def __repr__ (self): # instance of your version class) -class StrictVersion (Version): - +class StrictVersion(Version): """Version numbering for anal retentives and software idealists. Implements the standard interface for version number classes as described above. A version number consists of two or three @@ -103,7 +102,7 @@ class StrictVersion (Version): re.VERBOSE) - def parse (self, vstring): + def parse(self, vstring): match = self.version_re.match(vstring) if not match: raise ValueError, "invalid version number '%s'" % vstring @@ -122,7 +121,7 @@ def parse (self, vstring): self.prerelease = None - def __str__ (self): + def __str__(self): if self.version[2] == 0: vstring = string.join(map(str, self.version[0:2]), '.') @@ -135,12 +134,12 @@ def __str__ (self): return vstring - def __cmp__ (self, other): + def __cmp__(self, other): if isinstance(other, StringType): other = StrictVersion(other) compare = cmp(self.version, other.version) - if (compare == 0): # have to compare prerelease + if (compare == 0): # have to compare prerelease # case 1: neither has prerelease; they're equal # case 2: self has prerelease, other doesn't; other is greater @@ -156,8 +155,8 @@ def __cmp__ (self, other): elif (self.prerelease and other.prerelease): return cmp(self.prerelease, other.prerelease) - else: # numeric versions don't match -- - return compare # prerelease stuff doesn't matter + else: # numeric versions don't match -- + return compare # prerelease stuff doesn't matter # end class StrictVersion @@ -227,8 +226,7 @@ def __cmp__ (self, other): # the Right Thing" (ie. the code matches the conception). But I'd rather # have a conception that matches common notions about version numbers. -class LooseVersion (Version): - +class LooseVersion(Version): """Version numbering for anarchists and software realists. Implements the standard interface for version number classes as described above. A version number consists of a series of numbers, @@ -262,12 +260,12 @@ class LooseVersion (Version): component_re = re.compile(r'(\d+ | [a-z]+ | \.)', re.VERBOSE) - def __init__ (self, vstring=None): + def __init__(self, vstring=None): if vstring: self.parse(vstring) - def parse (self, vstring): + def parse(self, vstring): # I've given up on thinking I can reconstruct the version string # from the parsed tuple -- so I just store the string here for # use by __str__ @@ -283,15 +281,15 @@ def parse (self, vstring): self.version = components - def __str__ (self): + def __str__(self): return self.vstring - def __repr__ (self): + def __repr__(self): return "LooseVersion ('%s')" % str(self) - def __cmp__ (self, other): + def __cmp__(self, other): if isinstance(other, StringType): other = LooseVersion(other) From 5f18b63cae4353e4a5d7f591b6c0b11de814bcb7 Mon Sep 17 00:00:00 2001 From: Avi Puchalapalli <git@puchalapalli.com> Date: Sat, 3 Oct 2015 23:57:47 -0400 Subject: [PATCH 44/70] Search Improvements, See Also fixes --- anknotes/Anki.py | 294 ++-- anknotes/AnkiNotePrototype.py | 61 +- anknotes/Controller.py | 12 +- anknotes/EvernoteNoteFetcher.py | 74 +- anknotes/EvernoteNotes.py | 31 +- anknotes/__main__.py | 270 ++-- anknotes/ankEvernote.py | 45 +- anknotes/constants.py | 6 +- anknotes/constants_user.py | 4 +- anknotes/counters.py | 39 +- anknotes/db.py | 180 ++- anknotes/detect_see_also_changes.py | 16 +- anknotes/extra/ancillary/xhtml-lat1.ent | 196 +++ anknotes/extra/ancillary/xhtml-special.ent | 80 + anknotes/extra/ancillary/xhtml-symbol.ent | 237 +++ anknotes/find_deleted_notes.py | 39 +- anknotes/html.py | 8 +- anknotes/imports.py | 50 + anknotes/logging.py | 141 +- anknotes/menu.py | 272 +++- anknotes/settings.py | 9 + anknotes/shared.py | 48 +- anknotes/stopwatch/__init__.py | 30 +- anknotes/structs.py | 48 +- anknotes_start.py | 4 +- anknotes_start_note_validation.py | 9 +- bs4/__init__.py | 355 ++++ bs4/builder/__init__.py | 307 ++++ bs4/builder/_html5lib.py | 222 +++ bs4/builder/_htmlparser.py | 244 +++ bs4/builder/_lxml.py | 179 +++ bs4/dammit.py | 792 +++++++++ bs4/element.py | 1347 ++++++++++++++++ bs4/testing.py | 515 ++++++ bs4/tests/__init__.py | 1 + bs4/tests/test_builder_registry.py | 141 ++ bs4/tests/test_docs.py | 36 + bs4/tests/test_html5lib.py | 58 + bs4/tests/test_htmlparser.py | 19 + bs4/tests/test_lxml.py | 75 + bs4/tests/test_soup.py | 368 +++++ bs4/tests/test_tree.py | 1695 ++++++++++++++++++++ 42 files changed, 7995 insertions(+), 562 deletions(-) create mode 100644 anknotes/extra/ancillary/xhtml-lat1.ent create mode 100644 anknotes/extra/ancillary/xhtml-special.ent create mode 100644 anknotes/extra/ancillary/xhtml-symbol.ent create mode 100644 anknotes/imports.py create mode 100644 bs4/__init__.py create mode 100644 bs4/builder/__init__.py create mode 100644 bs4/builder/_html5lib.py create mode 100644 bs4/builder/_htmlparser.py create mode 100644 bs4/builder/_lxml.py create mode 100644 bs4/dammit.py create mode 100644 bs4/element.py create mode 100644 bs4/testing.py create mode 100644 bs4/tests/__init__.py create mode 100644 bs4/tests/test_builder_registry.py create mode 100644 bs4/tests/test_docs.py create mode 100644 bs4/tests/test_html5lib.py create mode 100644 bs4/tests/test_htmlparser.py create mode 100644 bs4/tests/test_lxml.py create mode 100644 bs4/tests/test_soup.py create mode 100644 bs4/tests/test_tree.py diff --git a/anknotes/Anki.py b/anknotes/Anki.py index f08e917..4764ac4 100644 --- a/anknotes/Anki.py +++ b/anknotes/Anki.py @@ -2,6 +2,7 @@ ### Python Imports import shutil import sys +import re try: from pysqlite2 import dbapi2 as sqlite @@ -117,7 +118,7 @@ def add_evernote_notes(self, evernote_notes, update=False, log_update_if_unchang continue anki_note_prototype = AnkiNotePrototype(self, anki_field_info, ankiNote.TagNames, baseNote, notebookGuid=ankiNote.NotebookGuid, count=tmr.count, - count_update=tmr.counts.success, max_count=tmr.counts.max.val) + count_update=tmr.counts.success, max_count=tmr.max) anki_note_prototype._log_update_if_unchanged_ = log_update_if_unchanged anki_result = anki_note_prototype.update_note() if update else anki_note_prototype.add_note() if anki_result != -1: tmr.reportSuccess(update, True) @@ -140,11 +141,11 @@ def get_evernote_model_styles(): if MODELS.OPTIONS.IMPORT_STYLES: return '@import url("%s");' % FILES.ANCILLARY.CSS return file(os.path.join(FOLDERS.ANCILLARY, FILES.ANCILLARY.CSS), 'r').read() - def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False): + def add_evernote_model(self, mm, modelName, forceRebuild=False, cloze=False, allowForceRebuild=True): model = mm.byName(modelName) model_css = self.get_evernote_model_styles() templates = self.get_templates(modelName==MODELS.DEFAULT) - if model and modelName is MODELS.DEFAULT: + if model and modelName is MODELS.DEFAULT and allowForceRebuild: front = model['tmpls'][0]['qfmt'] evernote_account_info = get_evernote_account_ids() if not evernote_account_info.Valid: @@ -252,12 +253,12 @@ def get_templates(self, forceRebuild=False): self.templates["Back"] = self.templates["Front"].replace("<div id='Side-Front'>", "<div id='Side-Back'>") return self.templates - def add_evernote_models(self): + def add_evernote_models(self, allowForceRebuild=True): col = self.collection() mm = col.models self.evernoteModels = {} - forceRebuild = self.add_evernote_model(mm, MODELS.DEFAULT) + forceRebuild = self.add_evernote_model(mm, MODELS.DEFAULT, allowForceRebuild=allowForceRebuild) self.add_evernote_model(mm, MODELS.REVERSE_ONLY, forceRebuild) self.add_evernote_model(mm, MODELS.REVERSIBLE, forceRebuild) self.add_evernote_model(mm, MODELS.CLOZE, forceRebuild, True) @@ -374,126 +375,159 @@ def insert_toc_into_see_also(self): db = ankDB() db._db.row_factory = None results = db.all( - "SELECT s.source_evernote_guid, s.target_evernote_guid, n.title, n2.title FROM %s as s, %s as n, %s as n2 WHERE s.source_evernote_guid != s.target_evernote_guid AND n.guid = s.target_evernote_guid AND n2.guid = s.source_evernote_guid AND s.from_toc == 1 ORDER BY s.source_evernote_guid ASC, n.title ASC" % ( + "SELECT s.target_evernote_guid, s.source_evernote_guid, target_note.title, toc_note.title FROM %s as s, %s as target_note, %s as toc_note WHERE s.source_evernote_guid != s.target_evernote_guid AND target_note.guid = s.target_evernote_guid AND toc_note.guid = s.source_evernote_guid AND s.from_toc == 1 AND (target_note.title LIKE '%%Cervicitis%%' OR 1) ORDER BY target_note.title ASC" % ( TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES, TABLES.EVERNOTE.NOTES)) - all_guids = [x[0] for x in db.all("SELECT guid FROM %s WHERE tagNames NOT LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, TAGS.TOC))] + # results_bad = db.all( + # "SELECT s.target_evernote_guid, s.source_evernote_guid FROM {t_see} as s WHERE s.source_evernote_guid COUNT(SELECT * FROM {tn} WHERE guid = s.source_evernote_guid) )" % ( + # TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES, TABLES.EVERNOTE.NOTES)) + all_child_guids = db.list("SELECT guid FROM %s WHERE tagNames NOT LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, TAGS.TOC)) + all_toc_guids = db.list("SELECT guid FROM %s WHERE tagNames LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, TAGS.TOC)) grouped_results = {} - - toc_titles = {} + # assert [x for x in results if x[0] == 'f78e4dca-3b20-41f2-a4f9-ab6cb4b0c8e3'] + toc_titles = {} for row in results: - key = row[0] - value = row[1] - if key not in all_guids: continue - toc_titles[value] = row[2] - if key not in grouped_results: grouped_results[key] = [row[3], []] - grouped_results[key][1].append(value) - # log_dump(grouped_results, 'grouped_results', 'insert_toc', timestamp=False) + target_guid = row[0] + toc_guid = row[1] + if toc_guid not in all_toc_guids: continue + if target_guid not in all_toc_guids and target_guid not in all_child_guids: continue + if target_guid not in grouped_results: grouped_results[target_guid] = [row[2], []] + toc_titles[toc_guid] = row[3] + grouped_results[target_guid][1].append(toc_guid) + tmr = stopwatch.Timer(len(grouped_results), label='insert_toc') action_title = 'INSERT TOCS INTO ANKI NOTES' - log.banner(action_title + ': %d NOTES' % len(grouped_results), 'insert_toc') + log.banner(action_title + ': %d TARGET ANKI NOTES' % tmr.max, tmr.label, crosspost=[tmr.label+'-new', tmr.label+'-invalid'], clear=True) toc_separator = generate_evernote_span(u' | ', u'Links', u'See Also', bold=False) count = 0 count_update = 0 - max_count = len(grouped_results) - log.add(' <h1>INSERT TOC LINKS INTO ANKI NOTES: %d TOTAL NOTES</h1> <HR><BR><BR>' % max_count, 'see_also', timestamp=False, clear=True, + log.add(' <h1>%s: %d TOTAL NOTES</h1> <HR><BR><BR>' % (action_title, tmr.max), 'see_also', timestamp=False, clear=True, extension='htm') logged_missing_anki_note=False - for source_guid, source_guid_info in sorted(grouped_results.items(), key=lambda s: s[1][0]): - toc_guids = source_guid_info[1] - note_title = source_guid_info[0] - ankiNote = self.get_anki_note_from_evernote_guid(source_guid) + # sorted_results = sorted(grouped_results.items(), key=lambda s: s[1][0]) + # log.add(sorted_results) + for target_guid, target_guid_info in sorted(grouped_results.items(), key=lambda s: s[1][0]): + note_title, toc_guids = target_guid_info + ankiNote = self.get_anki_note_from_evernote_guid(target_guid) if not ankiNote: - log.dump(toc_guids, 'Missing Anki Note for ' + source_guid, 'insert_toc', timestamp=False, crosspost_to_default=False) + log.dump(toc_guids, 'Missing Anki Note for ' + target_guid, tmr.label, timestamp=False, crosspost_to_default=False) if not logged_missing_anki_note: log_error('%s: Missing Anki Note(s) for TOC entry. See insert_toc log for more details' % action_title) logged_missing_anki_note = True - else: - fields = get_dict_from_list(ankiNote.items()) - see_also_html = fields[FIELDS.SEE_ALSO] - content_links = find_evernote_links_as_guids(fields[FIELDS.CONTENT]) - see_also_links = find_evernote_links_as_guids(see_also_html) - new_tocs = set(toc_guids) - set(see_also_links) - set(content_links) - log.dump([new_tocs, toc_guids, see_also_links, content_links], 'TOCs for %s' % fields[FIELDS.TITLE] + ' vs ' + note_title , 'insert_toc_new_tocs', crosspost_to_default=False) - new_toc_count = len(new_tocs) - if new_toc_count > 0: - see_also_count = len(see_also_links) - has_ol = u'<ol' in see_also_html - has_ul = u'<ul' in see_also_html - has_list = has_ol or has_ul - see_also_new = " " - flat_links = (new_toc_count + see_also_count < 3 and not has_list) - toc_delimiter = u' ' if see_also_count is 0 else toc_separator - for toc_guid in toc_guids: - toc_title = toc_titles[toc_guid] - if flat_links: - toc_title = u'[%s]' % toc_title - toc_link = generate_evernote_link(toc_guid, toc_title, value='TOC') - see_also_new += (toc_delimiter + toc_link) if flat_links else (u'\n<li>%s</li>' % toc_link) - toc_delimiter = toc_separator + continue + fields = get_dict_from_list(ankiNote.items()) + see_also_html = fields[FIELDS.SEE_ALSO] + content_links = find_evernote_links_as_guids(fields[FIELDS.CONTENT]) + see_also_whole_links = find_evernote_links(see_also_html) + see_also_links = {x.Guid for x in see_also_whole_links} + invalid_see_also_links = {x for x in see_also_links if x not in all_child_guids and x not in all_toc_guids} + new_tocs = set(toc_guids) - see_also_links - set(content_links) + log.dump([new_tocs, toc_guids, invalid_see_also_links, see_also_links, content_links], 'TOCs for %s' % fields[FIELDS.TITLE] + ' vs ' + note_title , 'insert_toc_new_tocs', crosspost_to_default=False) + new_toc_count = len(new_tocs) + invalid_see_also_links_count = len(invalid_see_also_links) + if invalid_see_also_links_count > 0: + for link in see_also_whole_links: + if link.Guid not in invalid_see_also_links: continue + see_also_html = remove_evernote_link(link, see_also_html) + see_also_links -= invalid_see_also_links + see_also_count = len(see_also_links) + + if new_toc_count > 0: + has_ol = u'<ol' in see_also_html + has_ul = u'<ul' in see_also_html + has_list = has_ol or has_ul + see_also_new = " " + flat_links = (new_toc_count + see_also_count < 3 and not has_list) + toc_delimiter = u' ' if see_also_count is 0 else toc_separator + for toc_guid in toc_guids: + toc_title = toc_titles[toc_guid] if flat_links: - find_div_end = see_also_html.rfind('</div>') - if find_div_end > -1: - see_also_html = see_also_html[:find_div_end] + see_also_new + '\n' + see_also_html[find_div_end:] - see_also_new = '' + toc_title = u'[%s]' % toc_title + toc_link = generate_evernote_link(toc_guid, toc_title, value='TOC') + see_also_new += (toc_delimiter + toc_link) if flat_links else (u'\n<li>%s</li>' % toc_link) + toc_delimiter = toc_separator + if flat_links: + find_div_end = see_also_html.rfind('</div>') + if find_div_end > -1: + see_also_html = see_also_html[:find_div_end] + see_also_new + '\n' + see_also_html[find_div_end:] + see_also_new = '' + else: + see_also_toc_headers = {'ol': u'<br><div style="margin-top:5px;">\n%s</div><ol style="margin-top:3px;">' % generate_evernote_span( + '<u>TABLE OF CONTENTS</u>:', 'Levels', 'Auto TOC', escape=False)} + see_also_toc_headers['ul'] = see_also_toc_headers['ol'].replace('<ol ', '<ul ') + + if see_also_toc_headers['ul'] in see_also_html: + find_ul_end = see_also_html.rfind('</ul>') + see_also_html = see_also_html[:find_ul_end] + '</ol>' + see_also_html[find_ul_end + 5:] + see_also_html = see_also_html.replace(see_also_toc_headers['ul'], see_also_toc_headers['ol']) + if see_also_toc_headers['ol'] in see_also_html: + find_ol_end = see_also_html.rfind('</ol>') + see_also_html = see_also_html[:find_ol_end] + see_also_new + '\n' + see_also_html[find_ol_end:] + see_also_new = '' else: - see_also_toc_headers = {'ol': u'<br><div style="margin-top:5px;">\n%s</div><ol style="margin-top:3px;">' % generate_evernote_span( - '<u>TABLE OF CONTENTS</u>:', 'Levels', 'Auto TOC', escape=False)} - see_also_toc_headers['ul'] = see_also_toc_headers['ol'].replace('<ol ', '<ul ') - - if see_also_toc_headers['ul'] in see_also_html: - find_ul_end = see_also_html.rfind('</ul>') - see_also_html = see_also_html[:find_ul_end] + '</ol>' + see_also_html[find_ul_end + 5:] - see_also_html = see_also_html.replace(see_also_toc_headers['ul'], see_also_toc_headers['ol']) - if see_also_toc_headers['ol'] in see_also_html: - find_ol_end = see_also_html.rfind('</ol>') - see_also_html = see_also_html[:find_ol_end] + see_also_new + '\n' + see_also_html[find_ol_end:] - see_also_new = '' - else: - header_type = 'ul' if new_toc_count is 1 else 'ol' - see_also_new = see_also_toc_headers[header_type] + u'%s\n</%s>' % (see_also_new, header_type) - if see_also_count == 0: - see_also_html = generate_evernote_span(u'See Also:', 'Links', 'See Also') - see_also_html += see_also_new - see_also_html = see_also_html.replace('<ol>', '<ol style="margin-top:3px;">') - log.add('<h3>%s</h3><br>' % generate_evernote_span(fields[FIELDS.TITLE], 'Links', - 'TOC') + see_also_html + u'<HR>', 'see_also', - timestamp=False, extension='htm') - fields[FIELDS.SEE_ALSO] = see_also_html.replace('evernote:///', 'evernote://') - anki_note_prototype = AnkiNotePrototype(self, fields, ankiNote.tags, ankiNote, count=count, - count_update=count_update, max_count=max_count) - anki_note_prototype._log_update_if_unchanged_ = (new_toc_count > 0) - if anki_note_prototype.update_note(): - count_update += 1 - count += 1 + header_type = 'ul' if new_toc_count is 1 else 'ol' + see_also_new = see_also_toc_headers[header_type] + u'%s\n</%s>' % (see_also_new, header_type) + if see_also_count == 0: + see_also_html = generate_evernote_span(u'See Also:', 'Links', 'See Also') + see_also_html += see_also_new + see_also_html = see_also_html.replace('<ol>', '<ol style="margin-top:3px;">') + log.add('<h3>%s</h3><br>' % generate_evernote_span(fields[FIELDS.TITLE], 'Links', + 'TOC') + see_also_html + u'<HR>', 'see_also', + crosspost='see_also\\' + note_title , timestamp=False, extension='htm') + see_also_html = see_also_html.replace('evernote:///', 'evernote://') + changed = see_also_html != fields[FIELDS.SEE_ALSO] + crosspost=[] + if new_toc_count: crosspost.append(tmr.label+'-new') + if invalid_see_also_links: crosspost.append(tmr.label+'-invalid') + log.go(' %s | %2d TOTAL TOC''s | %s | %s | %s%s' % (format_count('%2d NEW TOC''s', new_toc_count), len(toc_guids), format_count('%2d EXISTING LINKS', see_also_count), format_count('%2d INVALID LINKS', invalid_see_also_links_count), ('*' if changed else ' ') * 3, note_title), tmr.label, crosspost=crosspost, timestamp=False) + + fields[FIELDS.SEE_ALSO] = see_also_html + anki_note_prototype = AnkiNotePrototype(self, fields, ankiNote.tags, ankiNote, count=count, + count_update=count_update, max_count=tmr.max) + anki_note_prototype._log_update_if_unchanged_ = (changed or new_toc_count + invalid_see_also_links_count > 0) + if anki_note_prototype.update_note(): count_update += 1 + count += 1 db._db.row_factory = sqlite.Row def extract_links_from_toc(self): - query_update_toc_links = "UPDATE %s SET is_toc = 1 WHERE " % TABLES.SEE_ALSO - delimiter = "" - ankDB().setrowfactory() - toc_entries = ankDB().execute("SELECT * FROM %s WHERE tagNames LIKE '%%,#TOC,%%'" % TABLES.EVERNOTE.NOTES) - for toc_entry in toc_entries: - toc_evernote_guid = toc_entry['guid'] - toc_link_title = toc_entry['title'] + db = ankDB() + db.setrowfactory() + toc_entries = db.all("SELECT * FROM %s WHERE tagNames LIKE '%%,%s,%%' ORDER BY title ASC" % (TABLES.EVERNOTE.NOTES, TAGS.TOC)) + db.execute("DELETE FROM %s WHERE from_toc = 1" % TABLES.SEE_ALSO) + l = Logger(timestamp=False, crosspost_to_default=False) + l.banner('EXTRACTING LINKS FROM %3d TOC ENTRIES' % len(toc_entries), clear=True, crosspost='error') + toc_guids = [] + for i, toc_entry in enumerate(toc_entries): + toc_evernote_guid, toc_link_title = toc_entry['guid'], toc_entry['title'] + toc_guids.append("'%s'" % toc_evernote_guid) toc_link_html = generate_evernote_span(toc_link_title, 'Links', 'TOC') - for enLink in find_evernote_links(toc_entry['content']): + enLinks = find_evernote_links(toc_entry['content']) + for enLink in enLinks: target_evernote_guid = enLink.Guid - link_number = 1 + ankDB().scalar("select COUNT(*) from %s WHERE source_evernote_guid = '%s' " % ( - TABLES.SEE_ALSO, target_evernote_guid)) - query = """INSERT INTO `%s`(`source_evernote_guid`, `number`, `uid`, `shard`, `target_evernote_guid`, `html`, `title`, `from_toc`, `is_toc`) SELECT '%s', %d, %d, '%s', '%s', '%s', '%s', 1, 1 FROM `%s` WHERE NOT EXISTS (SELECT * FROM `%s` WHERE `source_evernote_guid`='%s' AND `target_evernote_guid`='%s') LIMIT 1 """ % ( - TABLES.SEE_ALSO, target_evernote_guid, link_number, enLink.Uid, enLink.Shard, toc_evernote_guid, - toc_link_html.replace(u'\'', u'\'\''), toc_link_title.replace(u'\'', u'\'\''), TABLES.SEE_ALSO, - TABLES.SEE_ALSO, target_evernote_guid, toc_evernote_guid) - log_sql('UPDATE_ANKI_DB: Add See Also Link: SQL Query: ' + query) - ankDB().execute(query) - query_update_toc_links += delimiter + "target_evernote_guid = '%s'" % toc_evernote_guid - delimiter = " OR " - ankDB().execute(query_update_toc_links) - ankDB().commit() + if not check_evernote_guid_is_valid(target_evernote_guid): l.go("Invalid Target GUID for %-50s %s" % (toc_link_title + ':', target_evernote_guid), 'error') ; continue + base = {'t': TABLES.SEE_ALSO, 'child_guid': target_evernote_guid, 'uid': enLink.Uid, 'shard': enLink.Shard, 'toc_guid': toc_evernote_guid, 'l1': 'source', 'l2': 'source', 'from_toc': 0, 'is_toc': 0} + query_count = "select COUNT(*) from {t} WHERE source_evernote_guid = '{%s_guid}'" + toc = {'num': 1 + db.scalar((query_count % 'toc').format(**base)), + 'html': enLink.HTML.replace(u'\'', u'\'\''), + 'title': enLink.FullTitle.replace(u'\'', u'\'\''), + 'l1': 'target', + 'from_toc': 1 + } + # child = {'num': 1 + db.scalar((query_count % 'child').format(**base)), + # 'html': toc_link_html.replace(u'\'', u'\'\''), + # 'title': toc_link_title.replace(u'\'', u'\'\''), + # 'l2': 'target', + # 'is_toc': 1 + # } + query = u"INSERT OR REPLACE INTO `{t}`(`{l1}_evernote_guid`, `number`, `uid`, `shard`, `{l2}_evernote_guid`, `html`, `title`, `from_toc`, `is_toc`) VALUES('{child_guid}', {num}, {uid}, '{shard}', '{toc_guid}', '{html}', '{title}', {from_toc}, {is_toc})" + query_toc = query.format(**DictCaseInsensitive(base, toc)) + db.execute(query_toc) + # db.execute(query.format(**DictCaseInsensitive(base, child))) + l.go("\t\t - Added %2d child link(s) from TOC %s" % (len(enLinks), toc_link_title.encode('utf-8')) ) + db.execute("UPDATE %s SET is_toc = 1 WHERE target_evernote_guid IN (%s)" % (TABLES.SEE_ALSO, ', '.join(toc_guids))) + db.commit() def insert_toc_and_outline_contents_into_notes(self): linked_notes_fields = {} - source_guids = ankDB().list( - "select DISTINCT source_evernote_guid from %s WHERE is_toc = 1 ORDER BY source_evernote_guid ASC" % TABLES.SEE_ALSO) + source_guids = ankDB().list("select DISTINCT source_evernote_guid from %s WHERE is_toc = 1 OR is_outline = 1 ORDER BY source_evernote_guid ASC" % TABLES.SEE_ALSO) source_guids_count = len(source_guids) i = 0 for source_guid in source_guids: @@ -502,12 +536,8 @@ def insert_toc_and_outline_contents_into_notes(self): if not note: continue if TAGS.TOC in note.tags: continue for fld in note._model['flds']: - if FIELDS.TITLE in fld.get('name'): - note_title = note.fields[fld.get('ord')] - continue - if not note_title: - log_error("Could not find note title for %s for insert_toc_and_outline_contents_into_notes" % note.guid) - continue + if FIELDS.TITLE in fld.get('name'): note_title = note.fields[fld.get('ord')]; continue + if not note_title: log_error("Could not find note title for %s for insert_toc_and_outline_contents_into_notes" % note.guid); continue note_toc = "" note_outline = "" toc_header = "" @@ -515,8 +545,7 @@ def insert_toc_and_outline_contents_into_notes(self): toc_count = 0 outline_count = 0 toc_and_outline_links = ankDB().execute( - "select target_evernote_guid, is_toc, is_outline from %s WHERE source_evernote_guid = '%s' AND (is_toc = 1 OR is_outline = 1) ORDER BY number ASC" % ( - TABLES.SEE_ALSO, source_guid)) + "select target_evernote_guid, is_toc, is_outline from %s WHERE source_evernote_guid = '%s' AND (is_toc = 1 OR is_outline = 1) ORDER BY number ASC" % (TABLES.SEE_ALSO, source_guid)) for target_evernote_guid, is_toc, is_outline in toc_and_outline_links: if target_evernote_guid in linked_notes_fields: linked_note_contents = linked_notes_fields[target_evernote_guid][FIELDS.CONTENT] @@ -526,10 +555,8 @@ def insert_toc_and_outline_contents_into_notes(self): if not linked_note: continue linked_note_contents = u"" for fld in linked_note._model['flds']: - if FIELDS.CONTENT in fld.get('name'): - linked_note_contents = linked_note.fields[fld.get('ord')] - elif FIELDS.TITLE in fld.get('name'): - linked_note_title = linked_note.fields[fld.get('ord')] + if FIELDS.CONTENT in fld.get('name'): linked_note_contents = linked_note.fields[fld.get('ord')] + elif FIELDS.TITLE in fld.get('name'): linked_note_title = linked_note.fields[fld.get('ord')] if linked_note_contents: linked_notes_fields[target_evernote_guid] = { FIELDS.TITLE: linked_note_title, @@ -542,39 +569,24 @@ def insert_toc_and_outline_contents_into_notes(self): log(" > [%3d/%3d] Found TOC/Outline for Note '%s': %s" % (i, source_guids_count, source_guid, note_title), 'See Also') if is_toc: toc_count += 1 - if toc_count is 1: - toc_header = "<span class='header'>TABLE OF CONTENTS</span>: 1. <span class='header'>%s</span>" % linked_note_title - else: - toc_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % ( - toc_count, linked_note_title) - note_toc += "<br><hr>" - + if toc_count is 1: toc_header = "<span class='header'>TABLE OF CONTENTS</span>: 1. <span class='header'>%s</span>" % linked_note_title + else: note_toc += "<br><hr>"; toc_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % (toc_count, linked_note_title) note_toc += linked_note_contents log(" > Appending TOC #%d contents" % toc_count, 'See Also') else: outline_count += 1 - if outline_count is 1: - outline_header = "<span class='header'>OUTLINE</span>: 1. <span class='header'>%s</span>" % linked_note_title - else: - outline_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % ( - outline_count, linked_note_title) - note_outline += "<BR><HR>" - + if outline_count is 1: outline_header = "<span class='header'>OUTLINE</span>: 1. <span class='header'>%s</span>" % linked_note_title + else: note_outline += "<BR><HR>"; outline_header += "<span class='See_Also'> | </span> %d. <span class='header'>%s</span>" % (outline_count, linked_note_title) note_outline += linked_note_contents log(" > Appending Outline #%d contents" % outline_count, 'See Also') - - if outline_count + toc_count > 0: - if outline_count > 1: - note_outline = "<span class='Outline'>%s</span><BR><BR>" % outline_header + note_outline - if toc_count > 1: - note_toc = "<span class='TOC'>%s</span><BR><BR>" % toc_header + note_toc - for fld in note._model['flds']: - if FIELDS.TOC in fld.get('name'): - note.fields[fld.get('ord')] = note_toc - elif FIELDS.OUTLINE in fld.get('name'): - note.fields[fld.get('ord')] = note_outline - log(" > Flushing Note \r\n", 'See Also') - note.flush() + if outline_count + toc_count is 0: continue + if outline_count > 1: note_outline = "<span class='Outline'>%s</span><BR><BR>" % outline_header + note_outline + if toc_count > 1: note_toc = "<span class='TOC'>%s</span><BR><BR>" % toc_header + note_toc + for fld in note._model['flds']: + if FIELDS.TOC in fld.get('name'): note.fields[fld.get('ord')] = note_toc + elif FIELDS.OUTLINE in fld.get('name'): note.fields[fld.get('ord')] = note_outline + log(" > Flushing Note \r\n", 'See Also') + note.flush() def start_editing(self): self.window().requireReset() diff --git a/anknotes/AnkiNotePrototype.py b/anknotes/AnkiNotePrototype.py index b1aa919..2609fb9 100644 --- a/anknotes/AnkiNotePrototype.py +++ b/anknotes/AnkiNotePrototype.py @@ -142,20 +142,29 @@ def evernote_cloze_regex(self, match): self.__cloze_count__ = 1 return "%s{{c%d::%s}}%s" % (match.group(1), self.__cloze_count__, matchText, match.group(3)) + def regex_occlude_match(self, match): + matchText = match.group(0) + if 'class="Occluded"' in matchText or "class='Occluded'" in matchText: return matchText + return r'<<' + match.group('PrefixKeep') + ' <div class="occluded">' + match.group('OccludedText') + '</div>>>' + def process_note_see_also(self): - if not FIELDS.SEE_ALSO in self.Fields or not FIELDS.EVERNOTE_GUID in self.Fields: - return - ankDB().execute("DELETE FROM %s WHERE source_evernote_guid = '%s' " % (TABLES.SEE_ALSO, self.Guid)) + if not FIELDS.SEE_ALSO in self.Fields or not FIELDS.EVERNOTE_GUID in self.Fields: return + db = ankDB() + db.execute("DELETE FROM %s WHERE source_evernote_guid = '%s' " % (TABLES.SEE_ALSO, self.Guid)) link_num = 0 for enLink in find_evernote_links(self.Fields[FIELDS.SEE_ALSO]): + if not check_evernote_guid_is_valid(enLink.Guid): self.Fields[FIELDS.SEE_ALSO] = remove_evernote_link(enLink, self.Fields[FIELDS.SEE_ALSO]); continue link_num += 1 - title_text = enLink.FullTitle - is_toc = 1 if (title_text == "TOC") else 0 - is_outline = 1 if (title_text is "O" or title_text is "Outline") else 0 - ankDB().execute( - "INSERT INTO %s (source_evernote_guid, number, uid, shard, target_evernote_guid, html, title, from_toc, is_toc, is_outline) VALUES('%s', %d, %d, '%s', '%s', '%s', '%s', 0, %d, %d)" % ( + title_text = enLink.FullTitle.replace(u'\'', u'\'\'') + from_toc = 1 if ',%s,' % TAGS.TOC in self.Tags else 0 + is_toc = 1 if (title_text == "TOC" or title_text == "TABLE OF CONTENTS") else 0 + is_outline = 1 if (title_text == "O" or title_text == "Outline") else 0 + db.execute( + "INSERT OR REPLACE INTO %s (source_evernote_guid, number, uid, shard, target_evernote_guid, html, title, from_toc, is_toc, is_outline) VALUES('%s', %d, %d, '%s', '%s', '%s', '%s', %d, %d, %d)" % ( TABLES.SEE_ALSO, self.Guid, link_num, enLink.Uid, enLink.Shard, - enLink.Guid, enLink.HTML, title_text, is_toc, is_outline)) + enLink.Guid, enLink.HTML, title_text, from_toc, is_toc, is_outline)) + if link_num is 0: self.Fields[FIELDS.SEE_ALSO] = "" + db.commit() def process_note_content(self): @@ -174,8 +183,8 @@ def step_1_modify_evernote_links(): self.Fields[FIELDS.CONTENT] = re.sub(r'https://www.evernote.com/shard/(s\d+)/[\w\d]+/(\d+)/([\w\d\-]+)', r'evernote://view/\2/\1/\3/\3/', self.Fields[FIELDS.CONTENT]) - if self.light_processing: - self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote://", "evernote:///") + # If we are converting back to Evernote format + if self.light_processing: self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace("evernote://", "evernote:///") def step_2_modify_image_links(): ################################### Step 2: Modify Image Links @@ -183,21 +192,22 @@ def step_2_modify_image_links(): # As a work around, this code will convert any link to an image on Dropbox, to an embedded <img> tag. # This code modifies the Dropbox link so it links to a raw image file rather than an interstitial web page # Step 2.1: Modify HTML links to Dropbox images - dropbox_image_url_base_regex = r'(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))' - dropbox_image_url_html_link_regex = dropbox_image_url_base_regex + r'(?P<QueryString>(?:\?dl=(?:0|1))?)' - dropbox_image_src_subst = r'<a href="\g<URL>\g<QueryString>"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>' - self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="%s"[^>]*>(?P<Title>.+?)</a>' % dropbox_image_url_html_link_regex, + dropbox_image_url_base_regex = r'(?P<URLPrefix>[^"''])(?P<URL>https://www.dropbox.com/s/[\w\d]+/.+\.(jpg|png|jpeg|gif|bmp))' + dropbox_image_url_html_link_regex = dropbox_image_url_base_regex + r'(?P<QueryString>(?:\?dl=(?:0|1))?)' + dropbox_image_url_suffix = r'(?P<URLSuffix>[^"''])' + dropbox_image_src_subst = r'\g<URLPrefix><a href="\g<URL>\g<QueryString>"><img src="\g<URL>?raw=1" alt="Dropbox Link %s Automatically Generated by Anknotes" /></a>\g<URLSuffix>' + self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="%s"[^>]*>(?P<Title>.+?)</a>' % (dropbox_image_url_html_link_regex + dropbox_image_url_suffix), dropbox_image_src_subst % "'\g<Title>'", self.Fields[FIELDS.CONTENT]) # Step 2.2: Modify Plain-text links to Dropbox images try: - dropbox_image_url_regex = dropbox_image_url_base_regex + r'(?P<QueryString>\?dl=(?:0|1))(?P<Suffix>"?[^">])' - self.Fields[FIELDS.CONTENT] = re.sub(dropbox_image_url_regex, (dropbox_image_src_subst % "From Plain-Text Link") + r'\g<Suffix>', self.Fields[FIELDS.CONTENT]) + dropbox_image_url_regex = dropbox_image_url_base_regex + r'(?P<QueryString>\?dl=(?:0|1))' + dropbox_image_url_suffix + self.Fields[FIELDS.CONTENT] = re.sub(dropbox_image_url_regex, r'\g<URLPrefix>' + dropbox_image_src_subst % "From Plain-Text Link" + r'\g<URLSuffix>', self.Fields[FIELDS.CONTENT]) except: log_error("\nERROR processing note, Step 2.2. Content: %s" % self.Fields[FIELDS.CONTENT]) - # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link)" - self.Fields[FIELDS.CONTENT] = re.sub(r'<a href="(?P<URL>.+?)"[^>]*>(?P<Title>\(Image Link.*\))</a>', + # Step 2.3: Modify HTML links with the inner text of exactly "(Image Link*)" + self.Fields[FIELDS.CONTENT] = re.sub(r'<a href=["''](?P<URL>.+?)["''][^>]*>(?P<Title>\(Image Link[^<]*\))</a>', r'''<img src="\g<URL>" alt="'\g<Title>' Automatically Generated by Anknotes" /> <BR><a href="\g<URL>">\g<Title></a>''', self.Fields[FIELDS.CONTENT]) @@ -208,7 +218,7 @@ def step_3_occlude_text(): self.Fields[FIELDS.CONTENT] = self.Fields[FIELDS.CONTENT].replace('<span style="color: rgb(255, 255, 255);">', '<span class="occluded">') ################################### Step 4: Automatically Occlude Text in <<Double Angle Brackets>> - self.Fields[FIELDS.CONTENT] = re.sub("(?s)(?P<Prefix><|<) ?(?P=Prefix) ?(?P<PrefixKeep>(?:</div>)?)(?P<OccludedText>.+?)(?P<Suffix>>|>) ?(?P=Suffix) ?", r'<<\g<PrefixKeep><div class="occluded">\g<OccludedText></div>>>', self.Fields[FIELDS.CONTENT]) + self.Fields[FIELDS.CONTENT] = re.sub("(?s)(?P<Prefix><|<) ?(?P=Prefix) ?(?P<PrefixKeep>(?:</div>)?)(?P<OccludedText>.+?)(?P<Suffix>>|>) ?(?P=Suffix) ?", self.regex_occlude_match, self.Fields[FIELDS.CONTENT]) def step_5_create_cloze_fields(): ################################### Step 5: Create Cloze fields from shorthand. Syntax is {Text}. Optionally {#Text} will prevent the Cloze # from incrementing. @@ -329,14 +339,15 @@ def log_update(self, content=''): count_str += '%-4d]' % self.Counts.Max count_str += ' (%2d%%)' % (float(self.Counts.Current) / self.Counts.Max * 100) log_title = '!' if content else '' - log_title += 'UPDATING NOTE%s: %-80s: %s' % (count_str, self.FullTitle, self.Guid) + log_title += 'UPDATING NOTE%s: %-80s %s' % (count_str, self.FullTitle + ':', self.Guid) log(log_title, 'AddUpdateNote', timestamp=(content is ''), clear=((self.Counts.Current == 1 or self.Counts.Current == 100) and not self.logged)) self.logged = True if not content: return content = obj2log_simple(content) content = content.replace('\n', '\n ') - log(' > %s\n' % content, 'AddUpdateNote', timestamp=False) + if content.lstrip()[:1] != '>': content = '> ' + content + log(' %s\n' % content, 'AddUpdateNote', timestamp=False) def update_note_tags(self): if len(self.Tags) == 0: return False @@ -441,12 +452,12 @@ def update_note(self): if not self.OriginalGuid: flds = get_dict_from_list(self.BaseNote.items()) self.OriginalGuid = get_evernote_guid_from_anki_fields(flds) - db_title = ankDB().scalar( - "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.OriginalGuid)) + db_title = get_evernote_title_from_guid(self.OriginalGuid) new_guid = self.Guid new_title = self.FullTitle self.check_titles_equal(db_title, new_title, new_guid) self.note.flush() + self.log_update(" > Flushing Note") self.update_note_model() self.Counts.Updated += 1 return 1 @@ -526,7 +537,7 @@ def add_note(self): self.create_note() if self.note is None: return -1 collection = self.Anki.collection() - db_title = ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, self.Guid)) + db_title = get_evernote_title_from_guid(self.Guid) log(' %s: ADD: ' % self.Guid + ' ' + self.FullTitle, 'AddUpdateNote') self.check_titles_equal(db_title, self.FullTitle, self.Guid, 'NEW NOTE TITLE UNEQUAL TO DB ENTRY') if self.add_note_try() is not 1: return -1 diff --git a/anknotes/Controller.py b/anknotes/Controller.py index 32eaa10..cdced6a 100644 --- a/anknotes/Controller.py +++ b/anknotes/Controller.py @@ -78,10 +78,10 @@ def upload_validated_notes(self, automated=False): if tmr.actionInitializationFailed: return tmr.status, 0, 0 for dbRow in dbRows: entry = EvernoteValidationEntry(dbRow) - evernote_guid, rootTitle, contents, tagNames, notebookGuid = entry.items() + evernote_guid, rootTitle, contents, tagNames, notebookGuid, noteType = entry.items() tagNames = tagNames.split(',') if not tmr.checkLimits(): break - whole_note = tmr.autoStep(self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid, validated=True), rootTitle, evernote_guid) + whole_note = tmr.autoStep(self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid, noteType=noteType, validated=True), rootTitle, evernote_guid) if tmr.report_result == False: raise ValueError if tmr.status.IsDelayableError: break if not tmr.status.IsSuccess: continue @@ -129,6 +129,8 @@ def check_old_values(): return evernote_guid, contents.replace('/guid-pending/', '/%s/' % evernote_guid).replace('/guid-pending/', '/%s/' % evernote_guid) update_regex() + noteType = 'create-auto_toc' + ankDB().execute("DELETE FROM %s WHERE noteType = '%s'" % (TABLES.NOTE_VALIDATION_QUEUE, noteType)) NotesDB = EvernoteNotes() NotesDB.baseQuery = ANKNOTES.HIERARCHY.ROOT_TITLES_BASE_QUERY dbRows = NotesDB.populateAllNonCustomRootNotes() @@ -138,8 +140,8 @@ def check_old_values(): """ info = stopwatch.ActionInfo('Creation of Table of Content Note(s)', row_source='Root Title(s)', enabled=EVERNOTE.UPLOAD.ENABLED) tmr = stopwatch.Timer(len(dbRows), 25, info, max_allowed=EVERNOTE.UPLOAD.MAX) - tmr.label = 'create-auto_toc' - if tmr.actionInitializationFailed: return tmr.tmr.status, 0, 0 + tmr.label = noteType + if tmr.actionInitializationFailed: return tmr.status, 0, 0 for dbRow in dbRows: evernote_guid = None rootTitle, contents, tagNames, notebookGuid = dbRow.items() @@ -148,7 +150,7 @@ def check_old_values(): evernote_guid, contents = check_old_values() if contents is None: continue if not tmr.checkLimits(): break - whole_note = tmr.autoStep(self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, guid=evernote_guid), rootTitle, evernote_guid) + whole_note = tmr.autoStep(self.evernote.makeNote(rootTitle, contents, tagNames, notebookGuid, noteType=noteType, guid=evernote_guid), rootTitle, evernote_guid) if tmr.report_result == False: raise ValueError if tmr.status.IsDelayableError: break if not tmr.status.IsSuccess: continue diff --git a/anknotes/EvernoteNoteFetcher.py b/anknotes/EvernoteNoteFetcher.py index ba2acf9..53ea0c2 100644 --- a/anknotes/EvernoteNoteFetcher.py +++ b/anknotes/EvernoteNoteFetcher.py @@ -11,7 +11,7 @@ class EvernoteNoteFetcher(object): - def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): + def __init__(self, evernote=None, guid=None, use_local_db_only=False): """ :type evernote: ankEvernote.Evernote @@ -20,19 +20,14 @@ def __init__(self, evernote=None, evernote_guid=None, use_local_db_only=False): self.results = EvernoteNoteFetcherResults() self.result = EvernoteNoteFetcherResult() self.api_calls = 0 - self.keepEvernoteTags = True - self.deleteQueryTags = True - self.evernoteQueryTags = [] - self.tagsToDelete = [] + self.keepEvernoteTags, self.deleteQueryTags = True, True + self.evernoteQueryTags, self.tagsToDelete = [], [] self.use_local_db_only = use_local_db_only self.__update_sequence_number__ = -1 - if evernote: self.evernote = evernote - if not evernote_guid: - self.evernote_guid = "" - return - self.evernote_guid = evernote_guid - if evernote and not self.use_local_db_only: - self.__update_sequence_number__ = self.evernote.metadata[self.evernote_guid].updateSequenceNum + self.evernote = evernote if evernote else None + if not guid: self.guid = ""; return + self.guid = guid + if evernote and not self.use_local_db_only: self.__update_sequence_number__ = self.evernote.metadata[self.guid].updateSequenceNum self.getNote() def __reset_data__(self): @@ -40,8 +35,7 @@ def __reset_data__(self): self.tagGuids = [] self.whole_note = None def UpdateSequenceNum(self): - if self.result.Note: - return self.result.Note.UpdateSequenceNum + if self.result.Note: return self.result.Note.UpdateSequenceNum return self.__update_sequence_number__ def reportSuccess(self, note, source=None): @@ -51,18 +45,15 @@ def reportResult(self, status=None, note=None, source=None): if note: self.result.Note = note status = EvernoteAPIStatus.Success - if not source: - source = 2 - if status: - self.result.Status = status - if source: - self.result.Source = source + if not source: source = 2 + if status: self.result.Status = status + if source: self.result.Source = source self.results.reportResult(self.result) def getNoteLocal(self): # Check Anknotes database for note query = "SELECT * FROM %s WHERE guid = '%s'" % ( - TABLES.EVERNOTE.NOTES, self.evernote_guid) + TABLES.EVERNOTE.NOTES, self.guid) if self.UpdateSequenceNum() > -1: query += " AND `updateSequenceNum` = %d" % self.UpdateSequenceNum() db_note = ankDB().first(query) @@ -70,23 +61,21 @@ def getNoteLocal(self): if not db_note: return False if not self.use_local_db_only: log(' ' + '-'*14 + ' '*5 + "> getNoteLocal: %s" % db_note['title'], 'api') - assert db_note['guid'] == self.evernote_guid + assert db_note['guid'] == self.guid self.reportSuccess(EvernoteNotePrototype(db_note=db_note), 1) - self.setNoteTags(tag_names=self.result.Note.TagNames) + self.setNoteTags(tag_names=self.result.Note.TagNames, tag_guids=self.result.Note.TagGuids) return True def setNoteTags(self, tag_names=None, tag_guids=None): - if not self.keepEvernoteTags: - self.tagNames = [] - self.tagGuids = [] - return - if not tag_names: - if self.tagNames: tag_names = self.tagNames - if not tag_names and self.result.Note: tag_names = self.result.Note.TagNames - if not tag_names and self.whole_note: tag_names = self.whole_note.tagNames - if not tag_names: tag_names = None - # if not tag_names: tag_names = self.tagNames if self.tagNames else (self.result.Note.TagNames if self.result.Note else (self.whole_note.tagNames if self.whole_note else None)) + if not self.keepEvernoteTags: self.tagGuids, self.tagNames = [], []; return + # if not tag_names: + # if self.tagNames: tag_names = self.tagNames + # if not tag_names and self.result.Note: tag_names = self.result.Note.TagNames + # if not tag_names and self.whole_note: tag_names = self.whole_note.tagNames + # if not tag_names: tag_names = None if not tag_guids: tag_guids = self.tagGuids if self.tagGuids else (self.result.Note.TagGuids if self.result.Note else (self.whole_note.tagGuids if self.whole_note else None)) + if not tag_names: tag_names = self.tagNames if self.tagNames else (self.result.Note.TagNames if self.result.Note else (self.whole_note.tagNames if self.whole_note else None)) + if not self.evernote or self.result.Source is 1: self.tagGuids, self.tagNames = tag_guids, tag_names; return self.tagGuids, self.tagNames = self.evernote.get_matching_tag_data(tag_guids, tag_names) def addNoteFromServerToDB(self, whole_note=None, tag_names=None): @@ -120,7 +109,6 @@ def addNoteFromServerToDB(self, whole_note=None, tag_names=None): self.whole_note.updateSequenceNum, self.whole_note.notebookGuid.decode('utf-8'), u',' + u','.join(self.tagGuids).decode('utf-8') + u',', tag_names) sql_query = sql_query_header + sql_query_columns - log_sql('UPDATE_ANKI_DB: Add Note: SQL Query: ' + sql_query) ankDB().execute(sql_query) sql_query = sql_query_header_history + sql_query_columns ankDB().execute(sql_query) @@ -133,9 +121,9 @@ def getNoteRemoteAPICall(self): return False api_action_str = u'trying to retrieve a note. We will save the notes downloaded thus far.' self.api_calls += 1 - log_api(" > getNote [%3d]" % self.api_calls, self.evernote_guid) + log_api(" > getNote [%3d]" % self.api_calls, self.guid) try: - self.whole_note = self.evernote.noteStore.getNote(self.evernote.token, self.evernote_guid, True, False, + self.whole_note = self.evernote.noteStore.getNote(self.evernote.token, self.guid, True, False, False, False) """:type : evernote.edam.type.ttypes.Note""" except EDAMSystemException as e: @@ -146,7 +134,7 @@ def getNoteRemoteAPICall(self): if not HandleSocketError(v, api_action_str) or EVERNOTE.API.DEBUG_RAISE_ERRORS: raise self.reportResult(EvernoteAPIStatus.SocketError) return False - assert self.whole_note.guid == self.evernote_guid + assert self.whole_note.guid == self.guid return True def getNoteRemote(self): @@ -154,7 +142,7 @@ def getNoteRemote(self): log("Aborting Evernote.getNoteRemote: EVERNOTE.IMPORT.API_CALLS_LIMIT of %d has been reached" % EVERNOTE.IMPORT.API_CALLS_LIMIT) return None if not self.getNoteRemoteAPICall(): return False - # self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_evernote_guids(self.whole_note.tagGuids) + # self.tagGuids, self.tagNames = self.evernote.get_tag_names_from_guids(self.whole_note.tagGuids) self.setNoteTags(tag_guids=self.whole_note.tagGuids) self.addNoteFromServerToDB() if not self.keepEvernoteTags: self.tagNames = [] @@ -165,14 +153,14 @@ def setNote(self, whole_note): self.whole_note = whole_note self.addNoteFromServerToDB() - def getNote(self, evernote_guid=None): + def getNote(self, guid=None): self.__reset_data__() - if evernote_guid: + if guid: self.result.Note = None - self.evernote_guid = evernote_guid - self.evernote.evernote_guid = evernote_guid + self.guid = guid + self.evernote.guid = guid self.__update_sequence_number__ = self.evernote.metadata[ - self.evernote_guid].updateSequenceNum if not self.use_local_db_only else -1 + self.guid].updateSequenceNum if not self.use_local_db_only else -1 if self.getNoteLocal(): return True if self.use_local_db_only: return False return self.getNoteRemote() diff --git a/anknotes/EvernoteNotes.py b/anknotes/EvernoteNotes.py index 82be22d..f1fa426 100644 --- a/anknotes/EvernoteNotes.py +++ b/anknotes/EvernoteNotes.py @@ -250,12 +250,12 @@ def populateAllPotentialRootNotes(self): processingFlags.populateMissingRootTitlesDict = True self.processingFlags = processingFlags - log(" CHECKING FOR ALL POTENTIAL ROOT TITLES ", 'RootTitles-TOC', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-TOC', timestamp=False) - log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) + log(" CHECKING FOR ALL POTENTIAL ROOT TITLES ", 'RootTitles\\TOC', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles\\TOC', timestamp=False) + log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles\\Isolated', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles\\Isolated', timestamp=False) self.getChildNotes() - log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-TOC', + log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles\\TOC', timestamp=False) self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) @@ -278,14 +278,14 @@ def populateAllRootNotesMissing(self, ignoreAutoTOCAsRootTitle=False, ignoreOutl # log(', '.join(self.RootNotesMissing.TitlesList)) self.getRootNotes() - log(" CHECKING FOR MISSING ROOT TITLES ", 'RootTitles-Missing', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-Missing', timestamp=False) - log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles-Isolated', clear=True, timestamp=False) - log("------------------------------------------------", 'RootTitles-Isolated', timestamp=False) - log("Total %d Existing Root Titles" % len(self.RootNotesExisting.TitlesList), 'RootTitles-Missing', + log(" CHECKING FOR MISSING ROOT TITLES ", 'RootTitles\\Missing', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles\\Missing', timestamp=False) + log(" CHECKING FOR ISOLATED ROOT TITLES ", 'RootTitles\\Isolated', clear=True, timestamp=False) + log("------------------------------------------------", 'RootTitles\\Isolated', timestamp=False) + log("Total %d Existing Root Titles" % len(self.RootNotesExisting.TitlesList), 'RootTitles\\Missing', timestamp=False) self.getChildNotes() - log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles-Missing', + log("Total %d Missing Root Titles" % len(self.RootNotesMissing.TitlesList), 'RootTitles\\Missing', timestamp=False) self.RootNotesMissing.TitlesList = sorted(self.RootNotesMissing.TitlesList, key=lambda s: s.lower()) @@ -325,11 +325,11 @@ def processAllRootNotesMissing(self): enChildNote = self.RootNotesMissing.ChildNotesDict[rootTitleStr][childGuid] # tags = enChildNote.Tags log(" > ISOLATED ROOT TITLE: [%-3d]: %-40s --> %-20s: %s %s" % ( - count_isolated, rootTitleStr + ':', childBaseTitle, childGuid, enChildNote), 'RootTitles-Isolated', + count_isolated, rootTitleStr + ':', childBaseTitle, childGuid, enChildNote), 'RootTitles\\Isolated', timestamp=False) else: count += 1 - log(" [%-3d] %s %s" % (count, rootTitleStr, '(O)' if outline else ' '), 'RootTitles-TOC', + log(" [%-3d] %s %s" % (count, rootTitleStr, '(O)' if outline else ' '), 'RootTitles\\TOC', timestamp=False) # tocList = TOCList(rootTitleStr) tocHierarchy = TOCHierarchyClass(rootTitleStr) @@ -352,11 +352,10 @@ def processAllRootNotesMissing(self): # childName = enChildNote.Title.Name # childTitle = enChildNote.FullTitle log(" %2d: %d. --> %-60s" % (count_child, level, childBaseTitle), - 'RootTitles-TOC', timestamp=False) + 'RootTitles\\TOC', timestamp=False) # tocList.generateEntry(childTitle, enChildNote) tocHierarchy.addNote(enChildNote) - realTitle = ankDB().scalar( - "SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, childGuid)) + realTitle = get_evernote_title_from_guid(childGuid) realTitle = realTitle[0:realTitle.index(':')] # realTitleUTF8 = realTitle.encode('utf8') notebookGuid = sorted(notebookGuids.items(), key=itemgetter(1), reverse=True)[0][0] diff --git a/anknotes/__main__.py b/anknotes/__main__.py index 8cd4077..050c58b 100644 --- a/anknotes/__main__.py +++ b/anknotes/__main__.py @@ -2,6 +2,8 @@ ### Python Imports import os import re, sre_constants +import sys +inAnki = 'anki' in sys.modules try: from pysqlite2 import dbapi2 as sqlite is_pysqlite = True @@ -10,12 +12,11 @@ is_pysqlite = False ### Anknotes Shared Imports from anknotes.shared import * +from anknotes import stopwatch ### Anknotes Main Imports from anknotes import menu, settings -### Evernote Imports - ### Anki Imports from anki.find import Finder from anki.hooks import wrap, addHook @@ -52,24 +53,112 @@ def _findEdited((val, args)): try: days = int(val) except ValueError: return return "c.mod > %d" % (time.time() - days * 86400) - -def _findHierarchy((val, args)): - if val == 'root': - return "n.sfld NOT LIKE '%:%' AND ank.title LIKE '%' || n.sfld || ':%'" - if val == 'sub': - return 'n.sfld like "%:%"' - if val == 'child': - return "UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) IN (SELECT UPPER(title) FROM %s WHERE title NOT LIKE '%%:%%' AND tagNames LIKE '%%,%s,%%') " % (TABLES.EVERNOTE.NOTES, TAGS.TOC) - if val == 'orphan': - return "n.sfld LIKE '%%:%%' AND UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) NOT IN (SELECT UPPER(title) FROM %s WHERE title NOT LIKE '%%:%%' AND tagNames LIKE '%%,%s,%%') " % (TABLES.EVERNOTE.NOTES, TAGS.TOC) - # showInfo(val) - + +def _findAnknotes((val, args)): + tmr = stopwatch.Timer(label='finder\\findAnknotes', begin=False) + log_banner("FINDANKNOTES SEARCH: " + val.upper().replace('_', ' ') , tmr.label, append_newline=False, clear=False) + if not hasattr(_findAnknotes, 'note_ids'): _findAnknotes.note_ids = {} + if val == 'hierarchical' or val == 'hierarchical_alt': + if val not in _findAnknotes.note_ids or not ANKNOTES.CACHE_SEARCHES: + tmr.reset() + val_root = val.replace('hierarchical', 'root') + val_child = val.replace('hierarchical', 'child') + _findAnknotes((val_root,None),); + _findAnknotes((val_child,None),) + _findAnknotes.note_ids[val] = _findAnknotes.note_ids[val_root] + _findAnknotes.note_ids[val_child] + log(" > %s Search Complete: ".ljust(25) % val.upper().replace('_', ' ') + "%-5s --> %3d results" % (tmr.str_long, len(_findAnknotes.note_ids[val])), tmr.label) + # return "c.nid IN (%s)" % ids2str(_findAnknotes.note_ids[val]) + + if not hasattr(_findAnknotes, 'queries'): + _findAnknotes.queries={ + 'all':get_evernote_model_ids(True), + 'sub': 'n.sfld like "%:%"', + 'root_alt': "n.sfld NOT LIKE '%:%' AND ank.title LIKE n.sfld || ':%'", + 'child_alt': "n.sfld LIKE '%%:%%' AND UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) IN (SELECT UPPER(title) FROM %s WHERE title NOT LIKE '%%:%%' AND tagNames LIKE '%%,%s,%%') " % (TABLES.EVERNOTE.NOTES, TAGS.TOC), + 'orphan_alt': "n.sfld LIKE '%%:%%' AND UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) NOT IN (SELECT UPPER(title) FROM %s WHERE title NOT LIKE '%%:%%' AND tagNames LIKE '%%,%s,%%') " % (TABLES.EVERNOTE.NOTES, TAGS.TOC) + } + # _findAnknotes.queries['hierarchical'] = '(%s) OR (%s)' % (_findAnknotes.queries['root'], _findAnknotes.queries['child']) + + # if not val in _findAnknotes.queries and not val in _findAnknotes.note_ids: + # if val == 'child_alt' or val == 'root_alt': + # if 'root_alt' not in _findAnknotes.note_ids: + # tmr.reset() + # _findAnknotes.note_ids['root_alt'] = get_anknotes_root_notes_nids() + # log(" > Cached Root TOC Notes NIDs: ".ljust(25) + "%-5s --> %3d results" % (tmr.str_long, len(_findAnknotes.note_ids['root_alt'])), tmr.label) + # if val == 'child_alt': + # if not in _findAnknotes.note_ids: + # tmr.reset() + # _findAnknotes.root_titles=["'%s'" % escape_text_sql(x.upper()) for x in get_evernote_title_from_nids(_findAnknotes.note_ids['root_alt'])] + # log(" > Cached Root Titles: ".ljust(25) + "%-5s --> %3d results" % (val, tmr.str_long, len(_findAnknotes.root_titles)), tmr.label) + # _findAnknotes.note_ids[val] = + # _findAnknotes.queries['child_alt'] = "n.sfld LIKE '%%:%%' AND UPPER(SUBSTR(n.sfld, 0, INSTR(n.sfld, ':'))) IN (%s) " % ', '.join(_findAnknotes.root_titles) + # elif val == 'root_alt': + # pass + # else: return None + if val not in _findAnknotes.note_ids or (not ANKNOTES.CACHE_SEARCHES and 'hierarchical' not in val): + tmr.reset() + if val == 'root': + _findAnknotes.note_ids[val] = get_anknotes_root_notes_nids() + elif val == 'child': + _findAnknotes.note_ids[val] = get_anknotes_child_notes_nids() + elif val == 'orphan': + _findAnknotes.note_ids[val] = get_anknotes_orphan_notes_nids() + elif val in _findAnknotes.queries: + pred = _findAnknotes.queries[val] + col = 'n.id' + table = 'notes n' + if 'ank.' in pred: + col = 'DISTINCT ' + col + table += ', %s ank' % TABLES.EVERNOTE.NOTES + sql = 'select %s from %s where ' % (col, table) + pred + _findAnknotes.note_ids[val] = ankDB().list(sql) + else: return None + log(" > Cached %s Note IDs: ".ljust(25) % val + "%-5s --> %3d results" % (tmr.str_long, len(_findAnknotes.note_ids[val])), tmr.label) + else: + log(" > Retrieving %3d %s Note IDs from Cache" % (len(_findAnknotes.note_ids[val]), val), tmr.label) + log_blank(tmr.label) + return "c.nid IN %s" % ids2str(_findAnknotes.note_ids[val]) + class CallbackItem(QTreeWidgetItem): def __init__(self, root, name, onclick, oncollapse=None): QTreeWidgetItem.__init__(self, root, [name]) self.onclick = onclick self.oncollapse = oncollapse - + +def anknotes_browser_get_icon(icon=None): + if icon: return QIcon(":/icons/" + icon) + if not hasattr(anknotes_browser_get_icon, 'default_icon'): + from anknotes.graphics import icoEvernoteWeb + anknotes_browser_get_icon.default_icon = icoEvernoteWeb + return anknotes_browser_get_icon.default_icon + +def anknotes_browser_add_treeitem(self, tree, name, cmd, icon=None, index=None, root=None): + if root is None: root = tree + onclick = lambda c=cmd: self.setFilter(c) + if index: + widgetItem = QTreeWidgetItem([_(name)]) + widgetItem.onclick = onclick + widgetItem.setIcon(0, anknotes_browser_get_icon(icon)) + root.insertTopLevelItem(index, widgetItem) + return root, tree + item = self.CallbackItem(tree, _(name),onclick) + item.setIcon(0, anknotes_browser_get_icon(icon)) + return root, tree + +def anknotes_browser_add_tree(self, tree, items, root=None, name=None, icon=None): + if root is None: root = tree + for item in items: + if isinstance(item[1], list): + new_name = item[0] + # log('Tree: Name: %s: \n' % str(new_name) + repr(item)) + new_tree = self.CallbackItem(tree, _(new_name), None) + new_tree.setExpanded(True) + new_tree.setIcon(0, anknotes_browser_get_icon(icon)) + root = anknotes_browser_add_tree(self, new_tree, item[1], root, new_name, icon); + else: + # log('Tree Item: Name: %s: \n' % str(name) + repr(item)) + root, tree = anknotes_browser_add_treeitem(self, tree, *item, root=root) + return root def anknotes_browser_tagtree_wrap(self, root, _old): """ @@ -77,133 +166,129 @@ def anknotes_browser_tagtree_wrap(self, root, _old): :type root : QTreeWidget :param _old: :return: - """ - tags = [ - (_("Edited This Week"), "view-pim-calendar.png", "edited:7"), - (_("Root Notes"), "hierarchy:root"), - (_("Sub Notes"), "hierarchy:sub"), - (_("Child Notes"), "hierarchy:child"), - (_("Orphan Notes"), "hierarchy:orphan") - ] - # tags.reverse() + """ root = _old(self, root) indices = root.findItems(_("Added Today"), Qt.MatchFixedString) index = (root.indexOfTopLevelItem(indices[0]) + 1) if indices else 3 - from anknotes.graphics import icoEvernoteWeb - for name, icon, cmd in tags[:1]: - onclick = lambda c=cmd: self.setFilter(c) - widgetItem = QTreeWidgetItem([name]) - widgetItem.onclick = onclick - widgetItem.setIcon(0, QIcon(":/icons/" + icon)) - root.insertTopLevelItem(index, widgetItem) - root = self.CallbackItem(root, _("Anknotes Hierarchy"), None) - root.setExpanded(True) - root.setIcon(0, icoEvernoteWeb) - for name, cmd in tags[1:]: - item = self.CallbackItem(root, name,lambda c=cmd: self.setFilter(c)) - item.setIcon(0, icoEvernoteWeb) - return root - -def _findField(self, field, val, _old=None): - def doCheck(self, field, val): - field = field.lower() - val = val.replace("*", "%") - # find models that have that field - mods = {} - for m in self.col.models.all(): - for f in m['flds']: - if f['name'].lower() == field: - mods[str(m['id'])] = (m, f['ord']) + tags = \ + [ + ["Edited This Week", "edited:7", "view-pim-calendar.png", index], + ["Anknotes", + [ + ["All Anknotes", "anknotes:all"], + ["Hierarchy", + [ + ["All Hierarchical Notes", "anknotes:hierarchical"], + ["Root Notes", "anknotes:root"], + ["Sub Notes", "anknotes:sub"], + ["Child Notes", "anknotes:child"], + ["Orphan Notes", "anknotes:orphan"] + ] + ], + # ["Hierarchy: Alt", + # [ + # ["All Hierarchical Notes", "anknotes:hierarchical_alt"], + # ["Root Notes", "anknotes:root_alt"], + # ["Child Notes", "anknotes:child_alt"], + # ["Orphan Notes", "anknotes:orphan_alt"] + # ] + # ], + ["Front Cards", "card:1"] + ] + ] + ] + + return anknotes_browser_add_tree(self, root, tags) - if not mods: - # nothing has that field - return - # gather nids - regex = re.escape(val).replace("\\_", ".").replace("\\%", ".*") - sql = """ -select id, mid, flds from notes -where mid in %s and flds like ? escape '\\'""" % ( - ids2str(mods.keys())) - nids = [] - for (id,mid,flds) in self.col.db.execute(sql, "%"+val+"%"): - flds = splitFields(flds) - ord = mods[str(mid)][1] - strg = flds[ord] - try: - if re.search("(?si)^"+regex+"$", strg): nids.append(id) - except sre_constants.error: return - if not nids: return "0" - return "n.id in %s" % ids2str(nids) - # val = doCheck(field, val) - vtest = doCheck(self, field, val) - log("FindField for %s: %s: Total %d matches " %(field, str(val), len(vtest.split(','))), 'sql-finder') - return vtest - # return _old(self, field, val) def anknotes_finder_findCards_wrap(self, query, order=False, _old=None): - log("Searching with text " + query , 'sql-finder') - "Return a list of card ids for QUERY." + tmr = stopwatch.Timer(label='finder\\findCards') + log_banner("FINDCARDS SEARCH: " + query , tmr.label, append_newline=False, clear=False) tokens = self._tokenize(query) preds, args = self._where(tokens) - log("Tokens: %-20s Preds: %-20s Args: %-20s " % (str(tokens), str(preds), str(args)) , 'sql-finder') + log('Tokens: '.ljust(25) + ', '.join(tokens) , tmr.label) + if args: log('Args: '.ljust(25) + ', '.join(tokens) , tmr.label) if preds is None: + log('Preds: '.ljust(25) + '<NONE>' , tmr.label) + log_blank(tmr.label) return [] + line_prefix = ' > ' + ' '*7 + # pred_str = preds + # pred_str = re.sub(r'(?si)\) (IN) \(', r')\n > \1 (', pred_str) + # pred_str = re.sub(r'(?si)\) (OR|AND) \(', r')\n > \1 (', pred_str) + # log('Preds: '.ljust(25) + pred_str , tmr.label) + order, rev = self._order(order) sql = self._query(preds, order) + # pred_str = sql + # pred_str = re.sub(r'(?si)\) (IN) \(', r')\n > \1 (', pred_str) + # pred_str = re.sub(r'(?si)(FROM|WHERE|ORDER|SELECT)', r'\n> \1 ', pred_str) + # pred_str = re.sub(r'(?si)\) (OR|AND) \(', r')\n > \1 (', pred_str) + # log('SQL: '.ljust(25) + pred_str , tmr.label) + # log('SQL: '.ljust(25) + pred_str , 'finder\\findCards') # showInfo(sql) try: res = self.col.db.list(sql, *args) except Exception as ex: # invalid grouping - log("Error with query %s: %s.\n%s" % (query, str(ex), [sql, args]) , 'sql-finder') + log_error("Error with findCards Query %s: %s.\n%s" % (query, str(ex), [sql, args]) , crosspost=tmr.label) return [] if rev: res.reverse() + log("FINDCARDS DONE: ".ljust(25) + "%-5s --> %3d results" % (tmr.str_long, len(res)), tmr.label) + log_blank(tmr.label) return res return _old(self, query, order) def anknotes_finder_query_wrap(self, preds=None, order=None, _old=None): if _old is None or not isinstance(self, Finder): - log_dump([self, preds, order], 'Finder Query Wrap Error', 'finder') + log_dump([self, preds, order], 'Finder Query Wrap Error', 'finder\\error', crosspost_to_default=False) return sql = _old(self, preds, order) if "ank." in preds: sql = sql.replace("select c.id", "select distinct c.id").replace("from cards c", "from cards c, %s ank" % TABLES.EVERNOTE.NOTES) - log('Custom anknotes finder SELECT query: \n%s' % sql, 'sql-finder') + log('Custom anknotes finder SELECT query: \n%s' % sql, 'finder\\ank-query') elif TABLES.EVERNOTE.NOTES in preds: - log('Custom anknotes finder alternate query: \n%s' % sql, 'sql-finder') + log('Custom anknotes finder alternate query: \n%s' % sql, 'finder\\ank-query') else: - log("Anki finder query: %s" % sql, 'sql-finder') + log("Anki finder query: %s" % sql[:100], 'finder\\query') return sql def anknotes_search_hook(search): - if not 'edited' in search: - search['edited'] = _findEdited - if not 'hierarchy' in search: - search['hierarchy'] = _findHierarchy + anknotes_search = {'edited': _findEdited, 'anknotes': _findAnknotes} + for key, value in anknotes_search.items(): + if key not in search: search[key] = anknotes_search[key] + # search = anknotes_search def reset_everything(): ankDB().InitSeeAlso(True) menu.resync_with_local_db() - menu.see_also([1, 2, 5, 6, 7]) + menu.see_also([1, 2, 4, 5, 6, 8]) def anknotes_profile_loaded(): if not os.path.exists(os.path.dirname(FILES.USER.LAST_PROFILE_LOCATION)): os.makedirs(os.path.dirname(FILES.USER.LAST_PROFILE_LOCATION)) with open(FILES.USER.LAST_PROFILE_LOCATION, 'w+') as myFile: print>> myFile, mw.pm.name menu.anknotes_load_menu_settings() - if EVERNOTE.UPLOAD.VALIDATION.ENABLED and EVERNOTE.UPLOAD.VALIDATION.AUTOMATED: - menu.upload_validated_notes(True) + if EVERNOTE.UPLOAD.VALIDATION.ENABLED and EVERNOTE.UPLOAD.VALIDATION.AUTOMATED: menu.upload_validated_notes(True) import_timer_toggle() - + if ANKNOTES.DEVELOPER_MODE.AUTOMATED: ''' For testing purposes only! Add a function here and it will automatically run on profile load You must create the files 'anknotes.developer' and 'anknotes.developer.automate' in the /extra/dev/ folder ''' + reset_everything() + return + # menu.see_also(set(range(0,10)) - {3,4,8}) + ankDB().InitSeeAlso(True) + # menu.resync_with_local_db() + menu.see_also([1, 2, 6, 7, 9]) + menu.lxml_test() + # menu.see_also() # reset_everything() - menu.see_also([7]) + # menu.see_also([7]) # menu.resync_with_local_db() # menu.see_also([1, 2, 5, 6, 7]) @@ -217,18 +302,15 @@ def anknotes_profile_loaded(): # menu.see_also([3,4]) # menu.resync_with_local_db() pass - + def anknotes_onload(): - addHook("profileLoaded", anknotes_profile_loaded) addHook("search", anknotes_search_hook) Finder._query = wrap(Finder._query, anknotes_finder_query_wrap, "around") - Finder._findField = wrap(Finder._findField, _findField, "around" ) Finder.findCards = wrap(Finder.findCards, anknotes_finder_findCards_wrap, "around") browser.Browser._systemTagTree = wrap(browser.Browser._systemTagTree, anknotes_browser_tagtree_wrap, "around") menu.anknotes_setup_menu() Preferences.setupOptions = wrap(Preferences.setupOptions, settings.setup_evernote) - anknotes_onload() # log("Anki Loaded", "load") diff --git a/anknotes/ankEvernote.py b/anknotes/ankEvernote.py index fed9abf..3edac0f 100644 --- a/anknotes/ankEvernote.py +++ b/anknotes/ankEvernote.py @@ -6,11 +6,11 @@ from datetime import datetime, timedelta from StringIO import StringIO -try: - from lxml import etree - eTreeImported = True -except ImportError: - eTreeImported = False +# try: + # from lxml import etree + # eTreeImported = True +# except ImportError: + # eTreeImported = False inAnki='anki' in sys.modules try: @@ -53,18 +53,21 @@ class Evernote(object): tag_data = {} """:type : dict[str, anknotes.structs.EvernoteTag]""" DTD = None - hasValidator = None + __hasValidator__ = None token = None client = None """:type : EvernoteClient """ - + + def hasValidator(self): + if self.__hasValidator__ is None: self.__hasValidator__ = import_etree() + return self.__hasValidator__ + def __init__(self): - global eTreeImported, dbLocal self.tag_data = {} self.notebook_data = {} self.noteStore = None self.getNoteCount = 0 - self.hasValidator = eTreeImported + # self.hasValidator = eTreeImported if ankDBIsLocal(): log("Skipping Evernote client load (DB is Local)", 'client') return @@ -153,7 +156,7 @@ def validateNoteContent(self, content, title="Note Contents"): """ return self.validateNoteBody(self.makeNoteBody(content), title) - def updateNote(self, guid, noteTitle, noteBody, tagNames=list(), parentNotebook=None, resources=None): + def updateNote(self, guid, noteTitle, noteBody, tagNames=None, parentNotebook=None, noteType=None, resources=None): """ Update a Note instance with title and body Send Note object to user's account @@ -161,7 +164,7 @@ def updateNote(self, guid, noteTitle, noteBody, tagNames=list(), parentNotebook= :returns Status and Note """ if resources is None: resources = [] - return self.makeNote(noteTitle, noteBody, tagNames=tagNames, parentNotebook=parentNotebook, resources=resources, + return self.makeNote(noteTitle, noteBody, tagNames=tagNames, parentNotebook=parentNotebook, noteType=noteType, resources=resources, guid=guid) @staticmethod @@ -178,26 +181,21 @@ def makeNoteBody(content, resources=None, encode=True): return nBody @staticmethod - def addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=None, + def addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames=list(), parentNotebook=None, resources=None, noteType=None, guid=None): + if not noteType: noteType = 'Unspecified' if resources is None: resources = [] - sql = "FROM %s WHERE " % TABLES.NOTE_VALIDATION_QUEUE - if guid: - sql += "guid = '%s'" % guid - else: - sql += "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents)) + sql = "FROM %s WHERE noteType = '%s' AND " % (TABLES.NOTE_VALIDATION_QUEUE, noteType) + (("guid = '%s'" % guid) if guid else "title = '%s' AND contents = '%s'" % (escape_text_sql(noteTitle), escape_text_sql(noteContents))) statuses = ankDB().all('SELECT validation_status ' + sql) if len(statuses) > 0: if str(statuses[0]['validation_status']) == '1': return EvernoteAPIStatus.Success ankDB().execute("DELETE " + sql) - # log_sql(sql) - # log_sql([ guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook]) ankDB().execute( - "INSERT INTO %s(guid, title, contents, tagNames, notebookGuid) VALUES(?, ?, ?, ?, ?)" % TABLES.NOTE_VALIDATION_QUEUE, - guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook) + "INSERT INTO %s(guid, title, contents, tagNames, notebookGuid, noteType) VALUES(?, ?, ?, ?, ?, ?)" % TABLES.NOTE_VALIDATION_QUEUE, + guid, noteTitle, noteContents, ','.join(tagNames), parentNotebook, noteType) return EvernoteAPIStatus.RequestQueued - def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNotebook=None, resources=None, guid=None, + def makeNote(self, noteTitle=None, noteContents=None, tagNames=None, parentNotebook=None, resources=None, noteType=None, guid=None, validated=None, enNote=None): """ Create or Update a Note instance with title and body @@ -208,6 +206,7 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot :rtype : (EvernoteAPIStatus, EvernoteNote) :returns Status and Note """ + if tagNames is None: tagNames = [] if enNote: guid, noteTitle, noteContents, tagNames, parentNotebook = enNote.Guid, enNote.FullTitle, enNote.Content, enNote.Tags, enNote.NotebookGuid or parentNotebook if resources is None: resources = [] callType = "create" @@ -217,7 +216,7 @@ def makeNote(self, noteTitle=None, noteContents=None, tagNames=list(), parentNot else: validation_status = self.addNoteToMakeNoteQueue(noteTitle, noteContents, tagNames, parentNotebook, resources, guid) if not validation_status.IsSuccess and not self.hasValidator: return validation_status, None - + log('%s: %s: ' % ('+VALIDATOR ' if self.hasValidator else '' + noteType, str(validation_status), noteTitle), 'validation') ourNote = EvernoteNote() ourNote.title = noteTitle.encode('utf-8') if guid: callType = "update"; ourNote.guid = guid diff --git a/anknotes/constants.py b/anknotes/constants.py index 2d29876..b8191b3 100644 --- a/anknotes/constants.py +++ b/anknotes/constants.py @@ -25,6 +25,7 @@ class FDN: MAIN = DEFAULT_NAME ACTIVE = DEFAULT_NAME USE_CALLER_NAME = False + DISABLED = ['sql*'] class ANCILLARY: TEMPLATE = os.path.join(FOLDERS.ANCILLARY, 'FrontTemplate.htm') CSS = u'_AviAnkiCSS.css' @@ -48,6 +49,9 @@ class USER: class ANKNOTES: DATE_FORMAT = '%Y-%m-%d %H:%M:%S' + CACHE_SEARCHES = False + class LXML: + ENABLE_IN_ANKI = False class DEVELOPER_MODE: ENABLED = (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer'))) AUTOMATED = ENABLED and (os.path.isfile(os.path.join(FOLDERS.DEVELOPER, 'anknotes.developer.automate'))) @@ -94,7 +98,7 @@ class FIELDS: LIST = [TITLE, CONTENT, SEE_ALSO, EXTRA, TOC, OUTLINE, UPDATE_SEQUENCE_NUM] class ORD: - pass + EVERNOTE_GUID = 0 ORD.CONTENT = LIST.index(CONTENT) + 1 ORD.SEE_ALSO = LIST.index(SEE_ALSO) + 1 diff --git a/anknotes/constants_user.py b/anknotes/constants_user.py index 288c37f..fb2626d 100644 --- a/anknotes/constants_user.py +++ b/anknotes/constants_user.py @@ -15,4 +15,6 @@ modfile.close() # constants.EVERNOTE.API.IS_SANDBOXED = True -# constants.SETTINGS.EVERNOTE.AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + constants.EVERNOTE.API.CONSUMER_KEY + ("_SANDBOX" if constants.EVERNOTE.API.IS_SANDBOXED else "") \ No newline at end of file +# constants.SETTINGS.EVERNOTE.AUTH_TOKEN = 'anknotesEvernoteAuthToken_' + constants.EVERNOTE.API.CONSUMER_KEY + ("_SANDBOX" if constants.EVERNOTE.API.IS_SANDBOXED else "") +constants.EVERNOTE.UPLOAD.VALIDATION.AUTOMATED = False +constants.EVERNOTE.UPLOAD.ENABLED = False \ No newline at end of file diff --git a/anknotes/counters.py b/anknotes/counters.py index 61dba27..03fe510 100644 --- a/anknotes/counters.py +++ b/anknotes/counters.py @@ -5,6 +5,20 @@ from anknotes.constants import * inAnki='anki' in sys.modules +def item_to_list(item, list_from_unknown=True,chrs=''): + if isinstance(item, list): return item + if item and (isinstance(item, unicode) or isinstance(item, str)): + for c in chrs: item=item.replace(c, '|') + return item.split('|') + if list_from_unknown: return [item] + return item + +def item_to_set(item, **kwargs): + if isinstance(item, set): return item + item = item_to_list(item, **kwargs) + if not isinstance(item, list): return item + return set(item) + def print_banner(title): print "-" * max(ANKNOTES.FORMATTING.COUNTER_BANNER_MINIMUM, len(title) + 5) print title @@ -35,11 +49,13 @@ def __init__(self, *args, **kwargs): # if not isinstance(label, unicode) and not isinstance(label, str): raise TypeError("Cannot create counter label from non-string type: " + str(label)) # print "kwargs: %s" % (str(kwargs)) lbl = self.__process_kwarg__(kwargs, 'label', 'root') - parent_lbl = self.__process_kwarg__(kwargs, 'parent_label', '') + parent_lbl = self.__process_kwarg__(kwargs, 'parent_label', '') + delete = self.__process_kwarg__(kwargs, 'delete', None) # print "lbl: %s\nkwargs: %s" % (lbl, str(kwargs)) self.__label__ = "root" self.__parent_label__ = "" - return super(DictCaseInsensitive, self).__init__(*args, **kwargs) + super(DictCaseInsensitive, self).__init__(*args, **kwargs) + if delete: self.delete_keys(delete) def reset(self, keys_to_keep=None): if keys_to_keep is None: keys_to_keep=self.__my_aggregates__.lower().split("|") @@ -59,6 +75,14 @@ def parent_label(self): return self.__parent_label__ @property def full_label(self): return self.parent_label + ('.' if self.parent_label else '') + self.label + def delete_keys(self, keys_to_delete): + keys = self.keys() + if not isinstance(keys_to_delete, list): keys_to_delete = item_to_list(keys_to_delete, chrs=' *,') + for key in keys_to_delete: + key = self.__key_transform__(key) + if key in keys: del self[key] + + def __setattr__(self, key, value): key_adj = self.__key_transform__(key) if key[0:1] + key[-1:] == '__': @@ -222,8 +246,8 @@ def sum(self): # print 'sum: ' + key + ': - ' + str(val) + ' ~ ' + str(sum) return sum - def increment(self, y=1, negate=False): - newCount = self.__sub__(y) if negate else self.__add__(y) + def increment(self, val=1, negate=False, **kwargs): + newCount = self.__sub__(val) if negate else self.__add__(val) # print "Incrementing %s by %d to %d" % (self.full_label, y, newCount) self.setCount(newCount) return newCount @@ -438,9 +462,4 @@ def test(): # Counts.updated.queued.step() # Counts.created.queued.step(7) Counts.print_banner("Evernote Counter") - # print Counts - -# if not inAnki and 'anknotes' not in sys.modules: test() - - - + # print Counts \ No newline at end of file diff --git a/anknotes/db.py b/anknotes/db.py index 104d6fb..628e4f0 100644 --- a/anknotes/db.py +++ b/anknotes/db.py @@ -1,19 +1,25 @@ ### Python Imports -from sqlite3 import dbapi2 as sqlite +try: from pysqlite2 import dbapi2 as sqlite +except ImportError: from sqlite3 import dbapi2 as sqlite +from datetime import datetime import time import os +import sys +inAnki = 'anki' in sys.modules ### Anki Shared Imports from anknotes.constants import * +from anknotes.logging import log_sql -try: +if inAnki: from aqt import mw -except: - pass + from anki.utils import ids2str, splitFields ankNotesDBInstance = None dbLocal = False +lastHierarchyUpdate=datetime.now() + def anki_profile_path_root(): return os.path.abspath(os.path.join(os.path.dirname(PATH), '..' + os.path.sep)) @@ -49,26 +55,156 @@ def escape_text_sql(title): return title.replace("'", "''") def delete_anki_notes_and_cards_by_guid(evernote_guids): - ankDB().executemany("DELETE FROM cards WHERE nid in (SELECT id FROM notes WHERE flds LIKE '%' || ? || '%'); " - + "DELETE FROM notes WHERE flds LIKE '%' || ? || '%'", - [[FIELDS.EVERNOTE_GUID_PREFIX + x, FIELDS.EVERNOTE_GUID_PREFIX + x] for x in evernote_guids]) + data=[[FIELDS.EVERNOTE_GUID_PREFIX + x] for x in evernote_guids] + db=ankDB() + db.executemany("DELETE FROM cards WHERE nid in (SELECT id FROM notes WHERE flds LIKE ? || '%')", data) + db.executemany("DELETE FROM notes WHERE flds LIKE ? || '%'", data) def get_evernote_title_from_guid(guid): return ankDB().scalar("SELECT title FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, guid)) -def get_anki_deck_id_from_note_id(nid): - return long(ankDB().scalar("SELECT did FROM cards WHERE nid = ?", nid)) - +def get_evernote_title_from_nids(nids): + return get_evernote_title_from_guids(nids, 'nid') + +def get_evernote_title_from_guids(guids,column='guid'): + return ankDB().list("SELECT title FROM %s WHERE %s IN (%s) ORDER BY title ASC" % (TABLES.EVERNOTE.NOTES, column, ', '.join(["'%s'" % x for x in guids]))) +def get_anki_deck_id_from_note_id(nid): + return long(ankDB().scalar("SELECT did FROM cards WHERE nid = ? LIMIT 1", nid)) + +def get_anki_card_ids_from_evernote_guids(guids,sql=None): + pred="n.flds LIKE '%s' || ? || '%%'" % FIELDS.EVERNOTE_GUID_PREFIX + if sql is None: sql = "SELECT c.id FROM cards c, notes n WHERE c.nid = n.id AND ({pred})" + return execute_sqlite_query(sql, guids, pred=pred) + +def get_anki_note_id_from_evernote_guid(guid): + return ankDB().scalar("SELECT n.id FROM notes n WHERE n.flds LIKE '%s' || ? || '%%'" % FIELDS.EVERNOTE_GUID_PREFIX, guid) + +def get_anki_note_ids_from_evernote_guids(guids): + return get_anki_card_ids_from_evernote_guids(guids, "SELECT n.id FROM notes n WHERE {pred}") + +def get_paired_anki_note_ids_from_evernote_guids(guids): + return get_anki_card_ids_from_evernote_guids([[x, x] for x in guids], "SELECT n.id, n.flds FROM notes n WHERE {pred}") + +def get_anknotes_root_notes_nids(): + return get_cached_data(get_anknotes_root_notes_nids, lambda: get_anknotes_root_notes_guids('nid')) + +def get_cached_data(func, data_generator,subkey=''): + if not ANKNOTES.CACHE_SEARCHES: return data_generator() + if subkey: subkey += '_' + if not hasattr(func, subkey + 'data') or getattr(func, subkey + 'update') < lastHierarchyUpdate: + setattr(func, subkey + 'data', data_generator()) + setattr(func, subkey + 'update', datetime.now()) + return getattr(func, subkey + 'data') + +def get_anknotes_root_notes_guids(column='guid', tag=None): + sql = "SELECT %s FROM %s WHERE UPPER(title) IN {pred}" % (column, TABLES.EVERNOTE.NOTES) + data_key=column + if tag: sql += " AND tagNames LIKE '%%,%s,%%'" % tag; data_key += '-' + tag + return get_cached_data(get_anknotes_root_notes_guids, lambda: execute_sqlite_in_query(sql, get_anknotes_potential_root_titles(upper_case=False,encode=False), pred='UPPER(?)'), data_key) + # return + # for query, data in queries: results += db.list(base % query, *data) + # return results + # data = ["'%s'" % escape_text_sql(x.upper() for x in get_anknotes_potential_root_titles()] + # return ankDB().list("SELECT guid FROM %s WHERE UPPER(title) IN (%s) AND tagNames LIKE '%%,%s,%%'" % (TABLES.EVERNOTE.NOTES, ', '.join(root_titles), TAGS.TOC)) + +def get_anknotes_root_notes_titles(): + return get_cached_data(get_anknotes_root_notes_titles, lambda: get_evernote_title_from_guids(get_anknotes_root_notes_guids())) + +def get_anknotes_potential_root_titles(upper_case=False, encode=False, **kwargs): + global generateTOCTitle + from anknotes.EvernoteNoteTitle import generateTOCTitle + mapper = lambda x: generateTOCTitle(x) + if upper_case: mapper = lambda x,f=mapper: f(x).upper() + if encode: mapper = lambda x,f=mapper: f(x).encode('utf-8') + data = get_cached_data(get_anknotes_potential_root_titles, lambda: ankDB().list("SELECT DISTINCT SUBSTR(title, 0, INSTR(title, ':')) FROM %s WHERE title LIKE '%%:%%'" % TABLES.EVERNOTE.NOTES)) + return map(mapper, data) + +# def __get_anknotes_root_notes_titles_query(): + # return '(%s)' % ' OR '.join(["title LIKE '%s'" % (escape_text_sql(x) + ':%') for x in get_anknotes_root_notes_titles()]) + +def __get_anknotes_root_notes_pred(base=None, column='guid', **kwargs): + if base is None: base = "SELECT %(column)s FROM %(table)s WHERE {pred} " + base = base % {'column': column, 'table': TABLES.EVERNOTE.NOTES} + pred = "title LIKE ? || ':%'" + return execute_sqlite_query(base, get_anknotes_root_notes_titles(), pred=pred) + + +def execute_sqlite_in_query(sql, data, in_query=True, **kwargs): + return execute_sqlite_query(sql, data, in_query=True, **kwargs) + +def execute_sqlite_query(sql, data, in_query=False, **kwargs): + queries = generate_sqlite_in_predicate(data, **kwargs) if in_query else generate_sqlite_predicate(data, **kwargs) + results=[] + db=ankDB() + for query, data in queries: + log_sql('FROM execute_sqlite_query ' + sql.format(pred=query), ['Data [%d]: ' % len(data), data, db.list(sql.format(pred=query), *data)[:3]]) + results += db.list(sql.format(pred=query), *data) + return results + +def generate_sqlite_predicate(data, pred='?', pred_delim=' OR ', query_base='(%s)', max_round=990): + if not query_base: query_base = '%s' + length = len(data) + rounds = float(length)/max_round + rounds = int(rounds) + 1 if int(rounds) < rounds else 0 + queries=[] + for i in range(0, rounds): + start = max_round * i + end = min(length, start+max_round) + # log_sql('FROM generate_sqlite_predicate ' + query_base, ['gen sql #%d of %d: %d-%d' % (i, rounds, start, end) , pred_delim, 'Data [%d]: ' % len(data), data[:3]]) + queries.append([query_base % (pred + (pred_delim + pred) * (end-start-1)), data[start:end]]) + return queries + +def generate_sqlite_in_predicate(data, pred='?', pred_delim=', ', query_base='(%s)'): + return generate_sqlite_predicate(data, pred=pred, query_base=query_base, pred_delim=pred_delim) + +def get_sql_anki_cids_from_evernote_guids(guids): + return "c.nid IN " + ids2str(get_anki_note_ids_from_evernote_guids(guids)) + +def get_anknotes_child_notes_nids(**kwargs): + if 'column' in kwargs: del kwargs['column'] + return get_anknotes_child_notes(column='nid',**kwargs) + +def get_anknotes_child_notes(column='guid', **kwargs): + return get_cached_data(get_anknotes_child_notes, lambda: __get_anknotes_root_notes_pred(column=column,**kwargs), column) + +def get_anknotes_orphan_notes_nids(**kwargs): + if 'column' in kwargs: del kwargs['column'] + return get_anknotes_orphan_notes(column='nid',**kwargs) + +def get_anknotes_orphan_notes(column='guid', **kwargs): + return get_cached_data(get_anknotes_orphan_notes, lambda: __get_anknotes_root_notes_pred("SELECT %(column)s FROM %(table)s WHERE title LIKE '%%:%%' AND NOT {pred}", column=column,**kwargs), column) + def get_evernote_guid_from_anki_fields(fields): - if not FIELDS.EVERNOTE_GUID in fields: return None - return fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') - - -def get_all_local_db_guids(): - return [x[0] for x in ankDB().all("SELECT guid FROM %s WHERE 1 ORDER BY title ASC" % TABLES.EVERNOTE.NOTES)] - + if isinstance(fields, dict): + if not FIELDS.EVERNOTE_GUID in fields: return None + return fields[FIELDS.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') + if isinstance(fields, str) or isinstance(fields, unicode): + fields = splitFields(fields) + return fields[FIELDS.ORD.EVERNOTE_GUID].replace(FIELDS.EVERNOTE_GUID_PREFIX, '') + +def get_all_local_db_guids(filter=None): + if filter is None: filter="1" + return ankDB().list("SELECT guid FROM %s WHERE %s ORDER BY title ASC" % (TABLES.EVERNOTE.NOTES, filter)) + +def get_evernote_model_ids(sql=False): + if not hasattr(get_evernote_model_ids, 'model_ids'): + from anknotes.Anki import Anki + anki = Anki() + anki.add_evernote_models(allowForceRebuild=False) + get_evernote_model_ids.model_ids = anki.evernoteModels + del anki + del Anki + if sql: return 'n.mid IN (%s)' % ', '.join(get_evernote_model_ids.model_ids.values()) + return get_evernote_model_ids.model_ids + +def update_anknotes_nids(): + db=ankDB() + paired_data = db.all("SELECT n.id, n.flds FROM notes n WHERE " + get_evernote_model_ids(True)) + paired_data=[[nid, get_evernote_guid_from_anki_fields(flds)] for nid, flds in paired_data] + db.executemany('UPDATE %s SET nid = ? WHERE guid = ?' % TABLES.EVERNOTE.NOTES, paired_data) + db.commit() class ank_DB(object): def __init__(self, path=None, text=None, timeout=0): @@ -92,7 +228,8 @@ def setrowfactory(self): self._db.row_factory = sqlite.Row def execute(self, sql, *a, **ka): - s = sql.strip().lower() + log_sql(sql, a, ka) + s = sql.strip().lower() # mark modified? for stmt in "insert", "update", "delete": if s.startswith(stmt): @@ -114,6 +251,7 @@ def execute(self, sql, *a, **ka): return res def executemany(self, sql, l): + log_sql(sql, l) self.mod = True t = time.time() self._db.executemany(sql, l) @@ -193,17 +331,17 @@ def InitSeeAlso(self, forceRebuild=False): self.commit() if_exists = "" self.execute( - """CREATE TABLE %s `%s` ( `id` INTEGER, `source_evernote_guid` TEXT NOT NULL, `number` INTEGER NOT NULL DEFAULT 100, `uid` INTEGER NOT NULL DEFAULT -1, `shard` TEXT NOT NULL DEFAULT -1, `target_evernote_guid` TEXT NOT NULL, `html` TEXT NOT NULL, `title` TEXT NOT NULL, `from_toc` INTEGER DEFAULT 0, `is_toc` INTEGER DEFAULT 0, `is_outline` INTEGER DEFAULT 0, PRIMARY KEY(id) );""" % (if_exists, TABLES.SEE_ALSO)) + """CREATE TABLE %s `%s` ( `id` INTEGER, `source_evernote_guid` TEXT NOT NULL, `number` INTEGER NOT NULL DEFAULT 100, `uid` INTEGER NOT NULL DEFAULT -1, `shard` TEXT NOT NULL DEFAULT -1, `target_evernote_guid` TEXT NOT NULL, `html` TEXT NOT NULL, `title` TEXT NOT NULL, `from_toc` INTEGER DEFAULT 0, `is_toc` INTEGER DEFAULT 0, `is_outline` INTEGER DEFAULT 0, PRIMARY KEY(id), unique(source_evernote_guid, target_evernote_guid) );""" % (if_exists, TABLES.SEE_ALSO)) def Init(self): self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL UNIQUE, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL, PRIMARY KEY(guid) );""" % TABLES.EVERNOTE.NOTES) + """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL UNIQUE, `nid` INTEGER NOT NULL DEFAULT -1, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL, PRIMARY KEY(guid) );""" % TABLES.EVERNOTE.NOTES) self.execute( """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `updated` INTEGER NOT NULL, `created` INTEGER NOT NULL, `updateSequenceNum` INTEGER NOT NULL, `notebookGuid` TEXT NOT NULL, `tagGuids` TEXT NOT NULL, `tagNames` TEXT NOT NULL)""" % TABLES.EVERNOTE.NOTES_HISTORY) self.execute( """CREATE TABLE IF NOT EXISTS `%s` ( `root_title` TEXT NOT NULL UNIQUE, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL, `notebookGuid` TEXT NOT NULL, PRIMARY KEY(root_title) );""" % TABLES.AUTO_TOC) self.execute( - """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT, `title` TEXT NOT NULL, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL DEFAULT ',,', `notebookGuid` TEXT, `validation_status` INTEGER NOT NULL DEFAULT 0, `validation_result` TEXT);""" % TABLES.NOTE_VALIDATION_QUEUE) + """CREATE TABLE IF NOT EXISTS `%s` ( `guid` TEXT, `title` TEXT NOT NULL, `contents` TEXT NOT NULL, `tagNames` TEXT NOT NULL DEFAULT ',,', `notebookGuid` TEXT, `validation_status` INTEGER NOT NULL DEFAULT 0, `validation_result` TEXT, `noteType` TEXT);""" % TABLES.NOTE_VALIDATION_QUEUE) self.InitSeeAlso() self.InitTags() self.InitNotebooks() diff --git a/anknotes/detect_see_also_changes.py b/anknotes/detect_see_also_changes.py index 9893a2a..87a7f00 100644 --- a/anknotes/detect_see_also_changes.py +++ b/anknotes/detect_see_also_changes.py @@ -212,8 +212,8 @@ def process_note(): n.match_type += 'V3' n.new.see_also.regex_original.subject = n.new.see_also.original + '</en-note>' if not n.new.see_also.regex_original.successful_match: - log.plain(enNote.Guid + '\n' + ', '.join(enNote.TagNames) + '\n' + n.new.see_also.original.content, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) - see_also_replace_old = n.old.content.original.match.processed.see_also.processed.content + log.plain(enNote.Guid + '\n' + ', '.join(enNote.TagNames) + '\n' + n.new.see_also.original, 'SeeAlsoNewMatchFail\\' + enNote.FullTitle, extension='htm', clear=True) + # see_also_replace_old = n.old.content.original.match.processed.see_also.processed.content n.old.see_also.updated = n.old.content.regex_updated.see_also n.new.see_also.updated = n.new.see_also.processed n.match_type + 'V4' @@ -227,12 +227,13 @@ def process_note(): # SELECT DISTINCT s.target_evernote_guid FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid ORDER BY n.title ASC # SELECT DISTINCT s.target_evernote_guid, n.* FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid ORDER BY n.title ASC; # SELECT DISTINCT s.target_evernote_guid, n.* FROM anknotes_see_also as s, anknotes_evernote_notes as n WHERE s.target_evernote_guid = n.guid AND n.tagNames NOT LIKE '%,#TOC,%' AND n.tagNames NOT LIKE '%,#Outline,%' ORDER BY n.title ASC; + noteType = 'SeeAlso-Step7' + ankDB().execute("DELETE FROM %s WHERE noteType = '%s'" % (TABLES.NOTE_VALIDATION_QUEUE, noteType)) sql = "SELECT DISTINCT s.target_evernote_guid, n.* FROM %s as s, %s as n WHERE s.target_evernote_guid = n.guid AND n.tagNames NOT LIKE '%%,%s,%%' AND n.tagNames NOT LIKE '%%,%s,%%' ORDER BY n.title ASC;" results = ankDB().all(sql % (TABLES.SEE_ALSO, TABLES.EVERNOTE.NOTES, TAGS.TOC, TAGS.OUTLINE)) # count_queued = 0 - tmr = stopwatch.Timer(len(results), 25, 'Updating See Also Notes', label='SeeAlso-Step7', display_initial_info=False) - log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), do_print=True) - log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), tmr.label) + tmr = stopwatch.Timer(len(results), 25, 'Updating See Also Notes', label=noteType, display_initial_info=False) + log.banner("UPDATING EVERNOTE SEE ALSO CONTENT: %d NOTES" % len(results), do_print=True, crosspost=tmr.label) notes_updated=[] # number_updated = 0 for result in results: @@ -256,8 +257,9 @@ def process_note(): print_results() print_results('Diff\\Contents', final=True) enNote.Content = n.new.content.final - if not evernote: evernote = Evernote() - whole_note = tmr.autoStep(evernote.makeNote(enNote=enNote), enNote.FullTitle, True) + if not EVERNOTE.UPLOAD.ENABLED: continue + if not evernote: evernote = Evernote() + whole_note = tmr.autoStep(evernote.makeNote(enNote=enNote, noteType=noteType), enNote.FullTitle, True) if tmr.reportStatus(status) == False: raise ValueError if tmr.status.IsDelayableError: break if tmr.status.IsSuccess: notes_updated.append(EvernoteNotePrototype(whole_note=whole_note)) diff --git a/anknotes/extra/ancillary/xhtml-lat1.ent b/anknotes/extra/ancillary/xhtml-lat1.ent new file mode 100644 index 0000000..ffee223 --- /dev/null +++ b/anknotes/extra/ancillary/xhtml-lat1.ent @@ -0,0 +1,196 @@ +<!-- Portions (C) International Organization for Standardization 1986 + Permission to copy in any form is granted for use with + conforming SGML systems and applications as defined in + ISO 8879, provided this notice is included in all copies. +--> +<!-- Character entity set. Typical invocation: + <!ENTITY % HTMLlat1 PUBLIC + "-//W3C//ENTITIES Latin 1 for XHTML//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml-lat1.ent"> + %HTMLlat1; +--> + +<!ENTITY nbsp " "> <!-- no-break space = non-breaking space, + U+00A0 ISOnum --> +<!ENTITY iexcl "¡"> <!-- inverted exclamation mark, U+00A1 ISOnum --> +<!ENTITY cent "¢"> <!-- cent sign, U+00A2 ISOnum --> +<!ENTITY pound "£"> <!-- pound sign, U+00A3 ISOnum --> +<!ENTITY curren "¤"> <!-- currency sign, U+00A4 ISOnum --> +<!ENTITY yen "¥"> <!-- yen sign = yuan sign, U+00A5 ISOnum --> +<!ENTITY brvbar "¦"> <!-- broken bar = broken vertical bar, + U+00A6 ISOnum --> +<!ENTITY sect "§"> <!-- section sign, U+00A7 ISOnum --> +<!ENTITY uml "¨"> <!-- diaeresis = spacing diaeresis, + U+00A8 ISOdia --> +<!ENTITY copy "©"> <!-- copyright sign, U+00A9 ISOnum --> +<!ENTITY ordf "ª"> <!-- feminine ordinal indicator, U+00AA ISOnum --> +<!ENTITY laquo "«"> <!-- left-pointing double angle quotation mark + = left pointing guillemet, U+00AB ISOnum --> +<!ENTITY not "¬"> <!-- not sign = angled dash, + U+00AC ISOnum --> +<!ENTITY shy "­"> <!-- soft hyphen = discretionary hyphen, + U+00AD ISOnum --> +<!ENTITY reg "®"> <!-- registered sign = registered trade mark sign, + U+00AE ISOnum --> +<!ENTITY macr "¯"> <!-- macron = spacing macron = overline + = APL overbar, U+00AF ISOdia --> +<!ENTITY deg "°"> <!-- degree sign, U+00B0 ISOnum --> +<!ENTITY plusmn "±"> <!-- plus-minus sign = plus-or-minus sign, + U+00B1 ISOnum --> +<!ENTITY sup2 "²"> <!-- superscript two = superscript digit two + = squared, U+00B2 ISOnum --> +<!ENTITY sup3 "³"> <!-- superscript three = superscript digit three + = cubed, U+00B3 ISOnum --> +<!ENTITY acute "´"> <!-- acute accent = spacing acute, + U+00B4 ISOdia --> +<!ENTITY micro "µ"> <!-- micro sign, U+00B5 ISOnum --> +<!ENTITY para "¶"> <!-- pilcrow sign = paragraph sign, + U+00B6 ISOnum --> +<!ENTITY middot "·"> <!-- middle dot = Georgian comma + = Greek middle dot, U+00B7 ISOnum --> +<!ENTITY cedil "¸"> <!-- cedilla = spacing cedilla, U+00B8 ISOdia --> +<!ENTITY sup1 "¹"> <!-- superscript one = superscript digit one, + U+00B9 ISOnum --> +<!ENTITY ordm "º"> <!-- masculine ordinal indicator, + U+00BA ISOnum --> +<!ENTITY raquo "»"> <!-- right-pointing double angle quotation mark + = right pointing guillemet, U+00BB ISOnum --> +<!ENTITY frac14 "¼"> <!-- vulgar fraction one quarter + = fraction one quarter, U+00BC ISOnum --> +<!ENTITY frac12 "½"> <!-- vulgar fraction one half + = fraction one half, U+00BD ISOnum --> +<!ENTITY frac34 "¾"> <!-- vulgar fraction three quarters + = fraction three quarters, U+00BE ISOnum --> +<!ENTITY iquest "¿"> <!-- inverted question mark + = turned question mark, U+00BF ISOnum --> +<!ENTITY Agrave "À"> <!-- latin capital letter A with grave + = latin capital letter A grave, + U+00C0 ISOlat1 --> +<!ENTITY Aacute "Á"> <!-- latin capital letter A with acute, + U+00C1 ISOlat1 --> +<!ENTITY Acirc "Â"> <!-- latin capital letter A with circumflex, + U+00C2 ISOlat1 --> +<!ENTITY Atilde "Ã"> <!-- latin capital letter A with tilde, + U+00C3 ISOlat1 --> +<!ENTITY Auml "Ä"> <!-- latin capital letter A with diaeresis, + U+00C4 ISOlat1 --> +<!ENTITY Aring "Å"> <!-- latin capital letter A with ring above + = latin capital letter A ring, + U+00C5 ISOlat1 --> +<!ENTITY AElig "Æ"> <!-- latin capital letter AE + = latin capital ligature AE, + U+00C6 ISOlat1 --> +<!ENTITY Ccedil "Ç"> <!-- latin capital letter C with cedilla, + U+00C7 ISOlat1 --> +<!ENTITY Egrave "È"> <!-- latin capital letter E with grave, + U+00C8 ISOlat1 --> +<!ENTITY Eacute "É"> <!-- latin capital letter E with acute, + U+00C9 ISOlat1 --> +<!ENTITY Ecirc "Ê"> <!-- latin capital letter E with circumflex, + U+00CA ISOlat1 --> +<!ENTITY Euml "Ë"> <!-- latin capital letter E with diaeresis, + U+00CB ISOlat1 --> +<!ENTITY Igrave "Ì"> <!-- latin capital letter I with grave, + U+00CC ISOlat1 --> +<!ENTITY Iacute "Í"> <!-- latin capital letter I with acute, + U+00CD ISOlat1 --> +<!ENTITY Icirc "Î"> <!-- latin capital letter I with circumflex, + U+00CE ISOlat1 --> +<!ENTITY Iuml "Ï"> <!-- latin capital letter I with diaeresis, + U+00CF ISOlat1 --> +<!ENTITY ETH "Ð"> <!-- latin capital letter ETH, U+00D0 ISOlat1 --> +<!ENTITY Ntilde "Ñ"> <!-- latin capital letter N with tilde, + U+00D1 ISOlat1 --> +<!ENTITY Ograve "Ò"> <!-- latin capital letter O with grave, + U+00D2 ISOlat1 --> +<!ENTITY Oacute "Ó"> <!-- latin capital letter O with acute, + U+00D3 ISOlat1 --> +<!ENTITY Ocirc "Ô"> <!-- latin capital letter O with circumflex, + U+00D4 ISOlat1 --> +<!ENTITY Otilde "Õ"> <!-- latin capital letter O with tilde, + U+00D5 ISOlat1 --> +<!ENTITY Ouml "Ö"> <!-- latin capital letter O with diaeresis, + U+00D6 ISOlat1 --> +<!ENTITY times "×"> <!-- multiplication sign, U+00D7 ISOnum --> +<!ENTITY Oslash "Ø"> <!-- latin capital letter O with stroke + = latin capital letter O slash, + U+00D8 ISOlat1 --> +<!ENTITY Ugrave "Ù"> <!-- latin capital letter U with grave, + U+00D9 ISOlat1 --> +<!ENTITY Uacute "Ú"> <!-- latin capital letter U with acute, + U+00DA ISOlat1 --> +<!ENTITY Ucirc "Û"> <!-- latin capital letter U with circumflex, + U+00DB ISOlat1 --> +<!ENTITY Uuml "Ü"> <!-- latin capital letter U with diaeresis, + U+00DC ISOlat1 --> +<!ENTITY Yacute "Ý"> <!-- latin capital letter Y with acute, + U+00DD ISOlat1 --> +<!ENTITY THORN "Þ"> <!-- latin capital letter THORN, + U+00DE ISOlat1 --> +<!ENTITY szlig "ß"> <!-- latin small letter sharp s = ess-zed, + U+00DF ISOlat1 --> +<!ENTITY agrave "à"> <!-- latin small letter a with grave + = latin small letter a grave, + U+00E0 ISOlat1 --> +<!ENTITY aacute "á"> <!-- latin small letter a with acute, + U+00E1 ISOlat1 --> +<!ENTITY acirc "â"> <!-- latin small letter a with circumflex, + U+00E2 ISOlat1 --> +<!ENTITY atilde "ã"> <!-- latin small letter a with tilde, + U+00E3 ISOlat1 --> +<!ENTITY auml "ä"> <!-- latin small letter a with diaeresis, + U+00E4 ISOlat1 --> +<!ENTITY aring "å"> <!-- latin small letter a with ring above + = latin small letter a ring, + U+00E5 ISOlat1 --> +<!ENTITY aelig "æ"> <!-- latin small letter ae + = latin small ligature ae, U+00E6 ISOlat1 --> +<!ENTITY ccedil "ç"> <!-- latin small letter c with cedilla, + U+00E7 ISOlat1 --> +<!ENTITY egrave "è"> <!-- latin small letter e with grave, + U+00E8 ISOlat1 --> +<!ENTITY eacute "é"> <!-- latin small letter e with acute, + U+00E9 ISOlat1 --> +<!ENTITY ecirc "ê"> <!-- latin small letter e with circumflex, + U+00EA ISOlat1 --> +<!ENTITY euml "ë"> <!-- latin small letter e with diaeresis, + U+00EB ISOlat1 --> +<!ENTITY igrave "ì"> <!-- latin small letter i with grave, + U+00EC ISOlat1 --> +<!ENTITY iacute "í"> <!-- latin small letter i with acute, + U+00ED ISOlat1 --> +<!ENTITY icirc "î"> <!-- latin small letter i with circumflex, + U+00EE ISOlat1 --> +<!ENTITY iuml "ï"> <!-- latin small letter i with diaeresis, + U+00EF ISOlat1 --> +<!ENTITY eth "ð"> <!-- latin small letter eth, U+00F0 ISOlat1 --> +<!ENTITY ntilde "ñ"> <!-- latin small letter n with tilde, + U+00F1 ISOlat1 --> +<!ENTITY ograve "ò"> <!-- latin small letter o with grave, + U+00F2 ISOlat1 --> +<!ENTITY oacute "ó"> <!-- latin small letter o with acute, + U+00F3 ISOlat1 --> +<!ENTITY ocirc "ô"> <!-- latin small letter o with circumflex, + U+00F4 ISOlat1 --> +<!ENTITY otilde "õ"> <!-- latin small letter o with tilde, + U+00F5 ISOlat1 --> +<!ENTITY ouml "ö"> <!-- latin small letter o with diaeresis, + U+00F6 ISOlat1 --> +<!ENTITY divide "÷"> <!-- division sign, U+00F7 ISOnum --> +<!ENTITY oslash "ø"> <!-- latin small letter o with stroke, + = latin small letter o slash, + U+00F8 ISOlat1 --> +<!ENTITY ugrave "ù"> <!-- latin small letter u with grave, + U+00F9 ISOlat1 --> +<!ENTITY uacute "ú"> <!-- latin small letter u with acute, + U+00FA ISOlat1 --> +<!ENTITY ucirc "û"> <!-- latin small letter u with circumflex, + U+00FB ISOlat1 --> +<!ENTITY uuml "ü"> <!-- latin small letter u with diaeresis, + U+00FC ISOlat1 --> +<!ENTITY yacute "ý"> <!-- latin small letter y with acute, + U+00FD ISOlat1 --> +<!ENTITY thorn "þ"> <!-- latin small letter thorn, + U+00FE ISOlat1 --> +<!ENTITY yuml "ÿ"> <!-- latin small letter y with diaeresis, + U+00FF ISOlat1 --> diff --git a/anknotes/extra/ancillary/xhtml-special.ent b/anknotes/extra/ancillary/xhtml-special.ent new file mode 100644 index 0000000..ca358b2 --- /dev/null +++ b/anknotes/extra/ancillary/xhtml-special.ent @@ -0,0 +1,80 @@ +<!-- Special characters for XHTML --> + +<!-- Character entity set. Typical invocation: + <!ENTITY % HTMLspecial PUBLIC + "-//W3C//ENTITIES Special for XHTML//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml-special.ent"> + %HTMLspecial; +--> + +<!-- Portions (C) International Organization for Standardization 1986: + Permission to copy in any form is granted for use with + conforming SGML systems and applications as defined in + ISO 8879, provided this notice is included in all copies. +--> + +<!-- Relevant ISO entity set is given unless names are newly introduced. + New names (i.e., not in ISO 8879 list) do not clash with any + existing ISO 8879 entity names. ISO 10646 character numbers + are given for each character, in hex. values are decimal + conversions of the ISO 10646 values and refer to the document + character set. Names are Unicode names. +--> + +<!-- C0 Controls and Basic Latin --> +<!ENTITY quot """> <!-- quotation mark, U+0022 ISOnum --> +<!ENTITY amp "&#38;"> <!-- ampersand, U+0026 ISOnum --> +<!ENTITY lt "&#60;"> <!-- less-than sign, U+003C ISOnum --> +<!ENTITY gt ">"> <!-- greater-than sign, U+003E ISOnum --> +<!ENTITY apos "'"> <!-- apostrophe = APL quote, U+0027 ISOnum --> + +<!-- Latin Extended-A --> +<!ENTITY OElig "Œ"> <!-- latin capital ligature OE, + U+0152 ISOlat2 --> +<!ENTITY oelig "œ"> <!-- latin small ligature oe, U+0153 ISOlat2 --> +<!-- ligature is a misnomer, this is a separate character in some languages --> +<!ENTITY Scaron "Š"> <!-- latin capital letter S with caron, + U+0160 ISOlat2 --> +<!ENTITY scaron "š"> <!-- latin small letter s with caron, + U+0161 ISOlat2 --> +<!ENTITY Yuml "Ÿ"> <!-- latin capital letter Y with diaeresis, + U+0178 ISOlat2 --> + +<!-- Spacing Modifier Letters --> +<!ENTITY circ "ˆ"> <!-- modifier letter circumflex accent, + U+02C6 ISOpub --> +<!ENTITY tilde "˜"> <!-- small tilde, U+02DC ISOdia --> + +<!-- General Punctuation --> +<!ENTITY ensp " "> <!-- en space, U+2002 ISOpub --> +<!ENTITY emsp " "> <!-- em space, U+2003 ISOpub --> +<!ENTITY thinsp " "> <!-- thin space, U+2009 ISOpub --> +<!ENTITY zwnj "‌"> <!-- zero width non-joiner, + U+200C NEW RFC 2070 --> +<!ENTITY zwj "‍"> <!-- zero width joiner, U+200D NEW RFC 2070 --> +<!ENTITY lrm "‎"> <!-- left-to-right mark, U+200E NEW RFC 2070 --> +<!ENTITY rlm "‏"> <!-- right-to-left mark, U+200F NEW RFC 2070 --> +<!ENTITY ndash "–"> <!-- en dash, U+2013 ISOpub --> +<!ENTITY mdash "—"> <!-- em dash, U+2014 ISOpub --> +<!ENTITY lsquo "‘"> <!-- left single quotation mark, + U+2018 ISOnum --> +<!ENTITY rsquo "’"> <!-- right single quotation mark, + U+2019 ISOnum --> +<!ENTITY sbquo "‚"> <!-- single low-9 quotation mark, U+201A NEW --> +<!ENTITY ldquo "“"> <!-- left double quotation mark, + U+201C ISOnum --> +<!ENTITY rdquo "”"> <!-- right double quotation mark, + U+201D ISOnum --> +<!ENTITY bdquo "„"> <!-- double low-9 quotation mark, U+201E NEW --> +<!ENTITY dagger "†"> <!-- dagger, U+2020 ISOpub --> +<!ENTITY Dagger "‡"> <!-- double dagger, U+2021 ISOpub --> +<!ENTITY permil "‰"> <!-- per mille sign, U+2030 ISOtech --> +<!ENTITY lsaquo "‹"> <!-- single left-pointing angle quotation mark, + U+2039 ISO proposed --> +<!-- lsaquo is proposed but not yet ISO standardized --> +<!ENTITY rsaquo "›"> <!-- single right-pointing angle quotation mark, + U+203A ISO proposed --> +<!-- rsaquo is proposed but not yet ISO standardized --> + +<!-- Currency Symbols --> +<!ENTITY euro "€"> <!-- euro sign, U+20AC NEW --> diff --git a/anknotes/extra/ancillary/xhtml-symbol.ent b/anknotes/extra/ancillary/xhtml-symbol.ent new file mode 100644 index 0000000..63c2abf --- /dev/null +++ b/anknotes/extra/ancillary/xhtml-symbol.ent @@ -0,0 +1,237 @@ +<!-- Mathematical, Greek and Symbolic characters for XHTML --> + +<!-- Character entity set. Typical invocation: + <!ENTITY % HTMLsymbol PUBLIC + "-//W3C//ENTITIES Symbols for XHTML//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml-symbol.ent"> + %HTMLsymbol; +--> + +<!-- Portions (C) International Organization for Standardization 1986: + Permission to copy in any form is granted for use with + conforming SGML systems and applications as defined in + ISO 8879, provided this notice is included in all copies. +--> + +<!-- Relevant ISO entity set is given unless names are newly introduced. + New names (i.e., not in ISO 8879 list) do not clash with any + existing ISO 8879 entity names. ISO 10646 character numbers + are given for each character, in hex. values are decimal + conversions of the ISO 10646 values and refer to the document + character set. Names are Unicode names. +--> + +<!-- Latin Extended-B --> +<!ENTITY fnof "ƒ"> <!-- latin small letter f with hook = function + = florin, U+0192 ISOtech --> + +<!-- Greek --> +<!ENTITY Alpha "Α"> <!-- greek capital letter alpha, U+0391 --> +<!ENTITY Beta "Β"> <!-- greek capital letter beta, U+0392 --> +<!ENTITY Gamma "Γ"> <!-- greek capital letter gamma, + U+0393 ISOgrk3 --> +<!ENTITY Delta "Δ"> <!-- greek capital letter delta, + U+0394 ISOgrk3 --> +<!ENTITY Epsilon "Ε"> <!-- greek capital letter epsilon, U+0395 --> +<!ENTITY Zeta "Ζ"> <!-- greek capital letter zeta, U+0396 --> +<!ENTITY Eta "Η"> <!-- greek capital letter eta, U+0397 --> +<!ENTITY Theta "Θ"> <!-- greek capital letter theta, + U+0398 ISOgrk3 --> +<!ENTITY Iota "Ι"> <!-- greek capital letter iota, U+0399 --> +<!ENTITY Kappa "Κ"> <!-- greek capital letter kappa, U+039A --> +<!ENTITY Lambda "Λ"> <!-- greek capital letter lamda, + U+039B ISOgrk3 --> +<!ENTITY Mu "Μ"> <!-- greek capital letter mu, U+039C --> +<!ENTITY Nu "Ν"> <!-- greek capital letter nu, U+039D --> +<!ENTITY Xi "Ξ"> <!-- greek capital letter xi, U+039E ISOgrk3 --> +<!ENTITY Omicron "Ο"> <!-- greek capital letter omicron, U+039F --> +<!ENTITY Pi "Π"> <!-- greek capital letter pi, U+03A0 ISOgrk3 --> +<!ENTITY Rho "Ρ"> <!-- greek capital letter rho, U+03A1 --> +<!-- there is no Sigmaf, and no U+03A2 character either --> +<!ENTITY Sigma "Σ"> <!-- greek capital letter sigma, + U+03A3 ISOgrk3 --> +<!ENTITY Tau "Τ"> <!-- greek capital letter tau, U+03A4 --> +<!ENTITY Upsilon "Υ"> <!-- greek capital letter upsilon, + U+03A5 ISOgrk3 --> +<!ENTITY Phi "Φ"> <!-- greek capital letter phi, + U+03A6 ISOgrk3 --> +<!ENTITY Chi "Χ"> <!-- greek capital letter chi, U+03A7 --> +<!ENTITY Psi "Ψ"> <!-- greek capital letter psi, + U+03A8 ISOgrk3 --> +<!ENTITY Omega "Ω"> <!-- greek capital letter omega, + U+03A9 ISOgrk3 --> + +<!ENTITY alpha "α"> <!-- greek small letter alpha, + U+03B1 ISOgrk3 --> +<!ENTITY beta "β"> <!-- greek small letter beta, U+03B2 ISOgrk3 --> +<!ENTITY gamma "γ"> <!-- greek small letter gamma, + U+03B3 ISOgrk3 --> +<!ENTITY delta "δ"> <!-- greek small letter delta, + U+03B4 ISOgrk3 --> +<!ENTITY epsilon "ε"> <!-- greek small letter epsilon, + U+03B5 ISOgrk3 --> +<!ENTITY zeta "ζ"> <!-- greek small letter zeta, U+03B6 ISOgrk3 --> +<!ENTITY eta "η"> <!-- greek small letter eta, U+03B7 ISOgrk3 --> +<!ENTITY theta "θ"> <!-- greek small letter theta, + U+03B8 ISOgrk3 --> +<!ENTITY iota "ι"> <!-- greek small letter iota, U+03B9 ISOgrk3 --> +<!ENTITY kappa "κ"> <!-- greek small letter kappa, + U+03BA ISOgrk3 --> +<!ENTITY lambda "λ"> <!-- greek small letter lamda, + U+03BB ISOgrk3 --> +<!ENTITY mu "μ"> <!-- greek small letter mu, U+03BC ISOgrk3 --> +<!ENTITY nu "ν"> <!-- greek small letter nu, U+03BD ISOgrk3 --> +<!ENTITY xi "ξ"> <!-- greek small letter xi, U+03BE ISOgrk3 --> +<!ENTITY omicron "ο"> <!-- greek small letter omicron, U+03BF NEW --> +<!ENTITY pi "π"> <!-- greek small letter pi, U+03C0 ISOgrk3 --> +<!ENTITY rho "ρ"> <!-- greek small letter rho, U+03C1 ISOgrk3 --> +<!ENTITY sigmaf "ς"> <!-- greek small letter final sigma, + U+03C2 ISOgrk3 --> +<!ENTITY sigma "σ"> <!-- greek small letter sigma, + U+03C3 ISOgrk3 --> +<!ENTITY tau "τ"> <!-- greek small letter tau, U+03C4 ISOgrk3 --> +<!ENTITY upsilon "υ"> <!-- greek small letter upsilon, + U+03C5 ISOgrk3 --> +<!ENTITY phi "φ"> <!-- greek small letter phi, U+03C6 ISOgrk3 --> +<!ENTITY chi "χ"> <!-- greek small letter chi, U+03C7 ISOgrk3 --> +<!ENTITY psi "ψ"> <!-- greek small letter psi, U+03C8 ISOgrk3 --> +<!ENTITY omega "ω"> <!-- greek small letter omega, + U+03C9 ISOgrk3 --> +<!ENTITY thetasym "ϑ"> <!-- greek theta symbol, + U+03D1 NEW --> +<!ENTITY upsih "ϒ"> <!-- greek upsilon with hook symbol, + U+03D2 NEW --> +<!ENTITY piv "ϖ"> <!-- greek pi symbol, U+03D6 ISOgrk3 --> + +<!-- General Punctuation --> +<!ENTITY bull "•"> <!-- bullet = black small circle, + U+2022 ISOpub --> +<!-- bullet is NOT the same as bullet operator, U+2219 --> +<!ENTITY hellip "…"> <!-- horizontal ellipsis = three dot leader, + U+2026 ISOpub --> +<!ENTITY prime "′"> <!-- prime = minutes = feet, U+2032 ISOtech --> +<!ENTITY Prime "″"> <!-- double prime = seconds = inches, + U+2033 ISOtech --> +<!ENTITY oline "‾"> <!-- overline = spacing overscore, + U+203E NEW --> +<!ENTITY frasl "⁄"> <!-- fraction slash, U+2044 NEW --> + +<!-- Letterlike Symbols --> +<!ENTITY weierp "℘"> <!-- script capital P = power set + = Weierstrass p, U+2118 ISOamso --> +<!ENTITY image "ℑ"> <!-- black-letter capital I = imaginary part, + U+2111 ISOamso --> +<!ENTITY real "ℜ"> <!-- black-letter capital R = real part symbol, + U+211C ISOamso --> +<!ENTITY trade "™"> <!-- trade mark sign, U+2122 ISOnum --> +<!ENTITY alefsym "ℵ"> <!-- alef symbol = first transfinite cardinal, + U+2135 NEW --> +<!-- alef symbol is NOT the same as hebrew letter alef, + U+05D0 although the same glyph could be used to depict both characters --> + +<!-- Arrows --> +<!ENTITY larr "←"> <!-- leftwards arrow, U+2190 ISOnum --> +<!ENTITY uarr "↑"> <!-- upwards arrow, U+2191 ISOnum--> +<!ENTITY rarr "→"> <!-- rightwards arrow, U+2192 ISOnum --> +<!ENTITY darr "↓"> <!-- downwards arrow, U+2193 ISOnum --> +<!ENTITY harr "↔"> <!-- left right arrow, U+2194 ISOamsa --> +<!ENTITY crarr "↵"> <!-- downwards arrow with corner leftwards + = carriage return, U+21B5 NEW --> +<!ENTITY lArr "⇐"> <!-- leftwards double arrow, U+21D0 ISOtech --> +<!-- Unicode does not say that lArr is the same as the 'is implied by' arrow + but also does not have any other character for that function. So lArr can + be used for 'is implied by' as ISOtech suggests --> +<!ENTITY uArr "⇑"> <!-- upwards double arrow, U+21D1 ISOamsa --> +<!ENTITY rArr "⇒"> <!-- rightwards double arrow, + U+21D2 ISOtech --> +<!-- Unicode does not say this is the 'implies' character but does not have + another character with this function so rArr can be used for 'implies' + as ISOtech suggests --> +<!ENTITY dArr "⇓"> <!-- downwards double arrow, U+21D3 ISOamsa --> +<!ENTITY hArr "⇔"> <!-- left right double arrow, + U+21D4 ISOamsa --> + +<!-- Mathematical Operators --> +<!ENTITY forall "∀"> <!-- for all, U+2200 ISOtech --> +<!ENTITY part "∂"> <!-- partial differential, U+2202 ISOtech --> +<!ENTITY exist "∃"> <!-- there exists, U+2203 ISOtech --> +<!ENTITY empty "∅"> <!-- empty set = null set, U+2205 ISOamso --> +<!ENTITY nabla "∇"> <!-- nabla = backward difference, + U+2207 ISOtech --> +<!ENTITY isin "∈"> <!-- element of, U+2208 ISOtech --> +<!ENTITY notin "∉"> <!-- not an element of, U+2209 ISOtech --> +<!ENTITY ni "∋"> <!-- contains as member, U+220B ISOtech --> +<!ENTITY prod "∏"> <!-- n-ary product = product sign, + U+220F ISOamsb --> +<!-- prod is NOT the same character as U+03A0 'greek capital letter pi' though + the same glyph might be used for both --> +<!ENTITY sum "∑"> <!-- n-ary summation, U+2211 ISOamsb --> +<!-- sum is NOT the same character as U+03A3 'greek capital letter sigma' + though the same glyph might be used for both --> +<!ENTITY minus "−"> <!-- minus sign, U+2212 ISOtech --> +<!ENTITY lowast "∗"> <!-- asterisk operator, U+2217 ISOtech --> +<!ENTITY radic "√"> <!-- square root = radical sign, + U+221A ISOtech --> +<!ENTITY prop "∝"> <!-- proportional to, U+221D ISOtech --> +<!ENTITY infin "∞"> <!-- infinity, U+221E ISOtech --> +<!ENTITY ang "∠"> <!-- angle, U+2220 ISOamso --> +<!ENTITY and "∧"> <!-- logical and = wedge, U+2227 ISOtech --> +<!ENTITY or "∨"> <!-- logical or = vee, U+2228 ISOtech --> +<!ENTITY cap "∩"> <!-- intersection = cap, U+2229 ISOtech --> +<!ENTITY cup "∪"> <!-- union = cup, U+222A ISOtech --> +<!ENTITY int "∫"> <!-- integral, U+222B ISOtech --> +<!ENTITY there4 "∴"> <!-- therefore, U+2234 ISOtech --> +<!ENTITY sim "∼"> <!-- tilde operator = varies with = similar to, + U+223C ISOtech --> +<!-- tilde operator is NOT the same character as the tilde, U+007E, + although the same glyph might be used to represent both --> +<!ENTITY cong "≅"> <!-- approximately equal to, U+2245 ISOtech --> +<!ENTITY asymp "≈"> <!-- almost equal to = asymptotic to, + U+2248 ISOamsr --> +<!ENTITY ne "≠"> <!-- not equal to, U+2260 ISOtech --> +<!ENTITY equiv "≡"> <!-- identical to, U+2261 ISOtech --> +<!ENTITY le "≤"> <!-- less-than or equal to, U+2264 ISOtech --> +<!ENTITY ge "≥"> <!-- greater-than or equal to, + U+2265 ISOtech --> +<!ENTITY sub "⊂"> <!-- subset of, U+2282 ISOtech --> +<!ENTITY sup "⊃"> <!-- superset of, U+2283 ISOtech --> +<!ENTITY nsub "⊄"> <!-- not a subset of, U+2284 ISOamsn --> +<!ENTITY sube "⊆"> <!-- subset of or equal to, U+2286 ISOtech --> +<!ENTITY supe "⊇"> <!-- superset of or equal to, + U+2287 ISOtech --> +<!ENTITY oplus "⊕"> <!-- circled plus = direct sum, + U+2295 ISOamsb --> +<!ENTITY otimes "⊗"> <!-- circled times = vector product, + U+2297 ISOamsb --> +<!ENTITY perp "⊥"> <!-- up tack = orthogonal to = perpendicular, + U+22A5 ISOtech --> +<!ENTITY sdot "⋅"> <!-- dot operator, U+22C5 ISOamsb --> +<!-- dot operator is NOT the same character as U+00B7 middle dot --> + +<!-- Miscellaneous Technical --> +<!ENTITY lceil "⌈"> <!-- left ceiling = APL upstile, + U+2308 ISOamsc --> +<!ENTITY rceil "⌉"> <!-- right ceiling, U+2309 ISOamsc --> +<!ENTITY lfloor "⌊"> <!-- left floor = APL downstile, + U+230A ISOamsc --> +<!ENTITY rfloor "⌋"> <!-- right floor, U+230B ISOamsc --> +<!ENTITY lang "〈"> <!-- left-pointing angle bracket = bra, + U+2329 ISOtech --> +<!-- lang is NOT the same character as U+003C 'less than sign' + or U+2039 'single left-pointing angle quotation mark' --> +<!ENTITY rang "〉"> <!-- right-pointing angle bracket = ket, + U+232A ISOtech --> +<!-- rang is NOT the same character as U+003E 'greater than sign' + or U+203A 'single right-pointing angle quotation mark' --> + +<!-- Geometric Shapes --> +<!ENTITY loz "◊"> <!-- lozenge, U+25CA ISOpub --> + +<!-- Miscellaneous Symbols --> +<!ENTITY spades "♠"> <!-- black spade suit, U+2660 ISOpub --> +<!-- black here seems to mean filled as opposed to hollow --> +<!ENTITY clubs "♣"> <!-- black club suit = shamrock, + U+2663 ISOpub --> +<!ENTITY hearts "♥"> <!-- black heart suit = valentine, + U+2665 ISOpub --> +<!ENTITY diams "♦"> <!-- black diamond suit, U+2666 ISOpub --> diff --git a/anknotes/find_deleted_notes.py b/anknotes/find_deleted_notes.py index 6e1ac9f..6a5a732 100644 --- a/anknotes/find_deleted_notes.py +++ b/anknotes/find_deleted_notes.py @@ -35,6 +35,9 @@ def do_find_deleted_notes(all_anki_notes=None): log_banner(' FIND DELETED EVERNOTE NOTES: POSSIBLE TOC NOTES MISSING TAG ', FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES + '_possibletoc') anki_mismatch = 0 is_toc_or_outline=[] + all_anki_notes = ankDB().all("SELECT n.sfld, n.flds FROM notes n WHERE n.flds LIKE ? || '%'", FIELDS.EVERNOTE_GUID_PREFIX) + all_anki_notes = {get_evernote_guid_from_anki_fields(flds): clean_title(sfld) for sfld, flds in all_anki_notes} + delete_title_mismatches=True for line in all_anknotes_notes: guid = line['guid'] title = line['title'] @@ -45,15 +48,16 @@ def do_find_deleted_notes(all_anki_notes=None): title = clean_title(title) title_safe = str_safe(title) find_guids[guid] = title - if all_anki_notes: - if guid in all_anki_notes: - find_title = all_anki_notes[guid][FIELDS.TITLE] - find_title_safe = str_safe(find_title) - if find_title_safe == title_safe: - del all_anki_notes[guid] - else: - log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES) - anki_mismatch += 1 + if guid in all_anki_notes: + find_title = clean_title(all_anki_notes[guid]) + find_title_safe = str_safe(find_title) + if find_title_safe == title_safe or find_title == title: + del all_anki_notes[guid] + else: + log_plain(guid + '::: ' + title + '\n ' + ' '*len(guid) + '::: ' + find_title, FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES) + log_plain(repr(find_title) + '\n ' + repr(title), FILES.LOGS.FDN.ANKI_TITLE_MISMATCHES + '-2') + anki_mismatch += 1 + if delete_title_mismatches: del all_anki_notes[guid] mismatch = 0 missing_evernote_notes = [] for enLink in find_evernote_links(enTableOfContents): @@ -62,23 +66,22 @@ def do_find_deleted_notes(all_anki_notes=None): title_safe = str_safe(title) if guid in find_guids: - find_title = find_guids[guid] + find_title = clean_title(find_guids[guid]) find_title_safe = str_safe(find_title) - if find_title_safe == title_safe: + if find_title_safe == title_safe or find_title == title: del find_guids[guid] else: - log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKNOTES_TITLE_MISMATCHES) + log_plain(guid + '::: ' + title + '\n ' + ' '*len(guid) + '::: ' + find_title, FILES.LOGS.FDN.ANKNOTES_TITLE_MISMATCHES) + if delete_title_mismatches: del find_guids[guid] mismatch += 1 else: log_plain(guid + '::: ' + title, FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES) missing_evernote_notes.append(guid) - anki_dels = [] - anknotes_dels = [] - if all_anki_notes: - for guid, fields in all_anki_notes.items(): - log_plain(guid + '::: ' + fields[FIELDS.TITLE], FILES.LOGS.FDN.ANKI_ORPHANS) - anki_dels.append(guid) + anki_dels, anknotes_dels = [], [] + for guid, title in all_anki_notes.items(): + log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKI_ORPHANS) + anki_dels.append(guid) for guid, title in find_guids.items(): log_plain(guid + '::: ' + title, FILES.LOGS.FDN.ANKNOTES_ORPHANS) anknotes_dels.append(guid) diff --git a/anknotes/html.py b/anknotes/html.py index bb4a723..bae7a60 100644 --- a/anknotes/html.py +++ b/anknotes/html.py @@ -20,13 +20,13 @@ def get_data(self): return ''.join(self.fed) -def strip_tags(html): +def strip_tags(html, strip_entities=False): if html is None: return None - html = html.replace('&', '__DONT_STRIP_HTML_ENTITIES___') + if not strip_entities: html = html.replace('&', '__DONT_STRIP_HTML_ENTITIES___') s = MLStripper() s.feed(html) html = s.get_data() - html = html.replace('__DONT_STRIP_HTML_ENTITIES___', '&') + if not strip_entities: html = html.replace('__DONT_STRIP_HTML_ENTITIES___', '&') return html # s = MLStripper() # s.feed(html) @@ -68,7 +68,7 @@ def clean_title(title): title = unescape_text(title) if isinstance(title, str): title = unicode(title, 'utf-8') - title = title.replace(u'\xa0', ' ') + title = re.sub(r'( |\xa0)+', ' ', title) return title diff --git a/anknotes/imports.py b/anknotes/imports.py new file mode 100644 index 0000000..ecdde53 --- /dev/null +++ b/anknotes/imports.py @@ -0,0 +1,50 @@ +import os +import imp +import sys +inAnki = 'anki' in sys.modules + +### Anknotes Imports +from anknotes.constants import * + +def import_module(name, path=None, sublevels=2, path_suffix=''): + print "Import " + str(path) + " Level " + str(sublevels) + if path is None: + path = os.path.dirname(__file__) + print "Auto Path " + path + for i in range(0, sublevels): + path = os.path.join(path, '..' + os.path.sep) + print "Path Level " + str(i) + " - " + path + if path_suffix: path = os.path.join(path, path_suffix) + path = os.path.abspath(path) + try: + modfile, modpath, description = imp.find_module(name, [path + os.path.sep]) + modobject=imp.load_module(name, modfile, modpath, description) + except ImportError, e: + print path + '\n' + str(e) + import pdb; import traceback; print traceback.format_exc(); pdb.set_trace() + return None + try: modfile.close() + except: pass + return modobject + +def import_anki_module(name): + return import_module(name, path_suffix='anki_master' + os.path.sep) + +def import_etree(): + global etree + global inAnki + if not ANKNOTES.LXML.ENABLE_IN_ANKI and inAnki: return False + if not import_lxml(): return False + try: from lxml import etree; return True + except: return False + +def import_lxml(): + global lxml + try: assert lxml; return True + except: pass + try: import lxml; return True + except ImportError, e: pass + import os + import imp + lxml = import_module('lxml') + return lxml is not None \ No newline at end of file diff --git a/anknotes/logging.py b/anknotes/logging.py index 0250996..fe6b2a9 100644 --- a/anknotes/logging.py +++ b/anknotes/logging.py @@ -6,10 +6,11 @@ import inspect import shutil import time +from fnmatch import fnmatch # Anknotes Shared Imports from anknotes.constants import * from anknotes.graphics import * -from anknotes.counters import DictCaseInsensitive +from anknotes.counters import DictCaseInsensitive, item_to_list, item_to_set # from anknotes.stopwatch import clockit # Anki Imports @@ -33,12 +34,13 @@ def print_safe(strr, prefix=''): print str_safe(strr, prefix) -def show_tooltip(text, time_out=7000, delay=None, do_log=False): - if do_log: log(text) +def show_tooltip(text, time_out=7000, delay=None, do_log=False, **kwargs): + if do_log: log(text, **kwargs) if delay: try: return mw.progress.timer(delay, lambda: tooltip(text, time_out), False) except: pass tooltip(text, time_out) + def counts_as_str(count, max=None): from anknotes.counters import Counter if isinstance(count, Counter): count = count.val @@ -46,6 +48,10 @@ def counts_as_str(count, max=None): if max is None or max <= 0: return str(count).center(3) if count == max: return "All %s" % str(count).center(3) return "Total %s of %s" % (str(count).center(3), str(max).center(3)) + +def format_count(format_str, count): + if not count > 0: return ' ' * len(format_str % 1) + return format_str % count def show_report(title, header=None, log_lines=None, delay=None, log_header_prefix = ' '*5, filename=None, blank_line_before=True, blank_line_after=True, hr_if_empty=False): if log_lines is None: log_lines = [] @@ -166,13 +172,6 @@ def PadLines(content, line_padding=ANKNOTES.FORMATTING.LINE_PADDING_HEADER, line if str(line_padding_plus).isdigit(): line_padding_plus = pad_char * int(line_padding_plus) return line_padding + content.replace('\n', '\n' + line_padding + line_padding_plus) -def item_to_list(item, list_from_unknown=True,chrs=''): - if isinstance(item, list): return item - if item and (isinstance(item, unicode) or isinstance(item, str)): - for c in chrs: item=item.replace(c, '|') - return item.split('|') - if list_from_unknown: return [item] - return item def key_transform(keys, key): if keys is None: keys = self.keys() key = key.strip() @@ -297,44 +296,55 @@ def convert_filename_to_local_link(filename): class Logger(object): base_path = None + path_suffix = None caller_info=None default_filename=None - def wrap_filename(self, filename=None): + defaults={} + def wrap_filename(self, filename=None, final_suffix='', **kwargs): if filename is None: filename = self.default_filename - if self.base_path is not None: - filename = os.path.join(self.base_path, filename if filename else '') - return filename - - def dump(self, obj, title='', filename=None, *args, **kwargs): - filename = self.wrap_filename(filename) + if self.base_path is not None: filename = os.path.join(self.base_path, filename if filename else '') + if self.path_suffix is not None: + i_asterisk = filename.find('*') + if i_asterisk > -1: final_suffix += filename[i_asterisk+1:]; filename=filename[:i_asterisk] + filename += self.path_suffix + final_suffix + if 'crosspost' in kwargs: + fns = [] + for cp in item_to_list(kwargs['crosspost'], False): fns.append(self.wrap_filename(cp)[0]) + kwargs['crosspost'] = fns + return filename, kwargs + + def dump(self, obj, title='', filename=None, *args, **kwargs): + filename, kwargs = self.wrap_filename(filename, **DictCaseInsensitive(self.defaults, kwargs)) log_dump(obj=obj, title=title, filename=filename, *args, **kwargs) def blank(self, filename=None, *args, **kwargs): - filename = self.wrap_filename(filename) + filename, kwargs = self.wrap_filename(filename, **DictCaseInsensitive(self.defaults, kwargs)) log_blank(filename=filename, *args, **kwargs) def banner(self, title, filename=None, *args, **kwargs): - filename = self.wrap_filename(filename) + filename, kwargs = self.wrap_filename(filename, **DictCaseInsensitive(self.defaults, kwargs)) log_banner(title=title, filename=filename, *args, **kwargs) def go(self, content=None, filename=None, wrap_filename=True, *args, **kwargs): - if wrap_filename: filename = self.wrap_filename(filename) + if wrap_filename: filename, kwargs = self.wrap_filename(filename, **DictCaseInsensitive(self.defaults, kwargs)); log(content=content, filename=filename, *args, **kwargs) def plain(self, content=None, filename=None, *args, **kwargs): - filename=self.wrap_filename(filename) + filename, kwargs=self.wrap_filename(filename, **DictCaseInsensitive(self.defaults, kwargs)) log_plain(content=content, filename=filename, *args, **kwargs) log = do = add = go def default(self, *args, **kwargs): - self.log(wrap_filename=False, *args, **kwargs) + self.log(wrap_filename=False, *args, **DictCaseInsensitive(self.defaults, kwargs)) - def __init__(self, base_path=None, default_filename=None, rm_path=False): + def __init__(self, base_path=None, default_filename=None, rm_path=False, no_base_path=None, **kwargs): + self.defaults = kwargs + if no_base_path and not default_filename: default_filename = no_base_path self.default_filename = default_filename if base_path: self.base_path = base_path - else: + elif not no_base_path: self.caller_info = caller_name() if self.caller_info: self.base_path = create_log_filename(self.caller_info.Base) @@ -343,12 +353,12 @@ def __init__(self, base_path=None, default_filename=None, rm_path=False): -def log_blank(filename=None, *args, **kwargs): - log(timestamp=False, content=None, filename=filename, *args, **kwargs) +def log_blank(filename=None, *args, **kwargs): + log(filename=filename, *args, **DictCaseInsensitive(kwargs, timestamp=False, content=None)) def log_plain(*args, **kwargs): - log(timestamp=False, *args, **kwargs) + log(*args, **DictCaseInsensitive(kwargs, timestamp=False)) def rm_log_path(filename='*', subfolders_only=False, retry_errors=0): path = os.path.dirname(os.path.abspath(get_log_full_path(filename))) @@ -369,9 +379,11 @@ def rmtree_error(f, p, e): time.sleep(1) rm_log_path(filename, subfolders_only, retry_errors + 1) -def log_banner(title, filename=None, length=ANKNOTES.FORMATTING.BANNER_MINIMUM, append_newline=True, timestamp=False, chr='-', center=True, clear=True, *args, **kwargs): +def log_banner(title, filename=None, length=ANKNOTES.FORMATTING.BANNER_MINIMUM, append_newline=True, timestamp=False, chr='-', center=True, clear=True, crosspost=None, *args, **kwargs): + if crosspost is not None: + for cp in item_to_list(crosspost, False): log_banner(title, cp, **DictCaseInsensitive(kwargs, locals(), delete='title crosspost kwargs args filename')) if length is 0: length = ANKNOTES.FORMATTING.LINE_LENGTH+1 - if center: title = title.center(length-TIMESTAMP_PAD_LENGTH if timestamp else 0) + if center: title = title.center(length-(ANKNOTES.FORMATTING.TIMESTAMP_PAD_LENGTH if timestamp else 0)) log(chr * length, filename, clear=clear, timestamp=False, *args, **kwargs) log(title, filename, timestamp=timestamp, *args, **kwargs) log(chr * length, filename, timestamp=False, *args, **kwargs) @@ -400,52 +412,51 @@ def get_log_full_path(filename=None, extension='log', as_url_link=False, prefix= if filename is None: if FILES.LOGS.USE_CALLER_NAME: caller = caller_name() - if caller: - filename = caller.Base.replace('.', '\\') - if filename is None: - filename = _log_filename_history[-1] if _log_filename_history else FILES.LOGS.ACTIVE + if caller: filename = caller.Base.replace('.', '\\') + if filename is None: filename = _log_filename_history[-1] if _log_filename_history else FILES.LOGS.ACTIVE if not filename: filename = log_base_name if not filename: filename = FILES.LOGS.DEFAULT_NAME else: - if filename[0] is '+': - filename = filename[1:] + if filename[0] is '+': filename = filename[1:] filename = (log_base_name + '-' if log_base_name and log_base_name[-1] != '\\' else '') + filename filename += filename_suffix - filename += ('.' if filename and filename[-1] is not '.' else '') + extension + if filename and filename[-1] == os.path.sep: filename += 'main' filename = re.sub(r'[^\w\-_\.\\]', '_', filename) + if filter(lambda x: fnmatch(filename, x), item_to_list(FILES.LOGS.DISABLED)): return False + filename += ('.' if filename and filename[-1] is not '.' else '') + extension full_path = os.path.join(FOLDERS.LOGS, filename) if prefix: parent, fn = os.path.split(full_path) if fn != '.' + extension: fn = '-' + fn full_path = os.path.join(parent, prefix + fn) - if not os.path.exists(os.path.dirname(full_path)): - os.makedirs(os.path.dirname(full_path)) + if not os.path.exists(os.path.dirname(full_path)): os.makedirs(os.path.dirname(full_path)) if as_url_link: return convert_filename_to_local_link(full_path) return full_path def encode_log_text(content, encode_text=True, **kwargs): - if not encode_text or not isinstance(content, str) and not isinstance(content, unicode): return content + if not encode_text or not isinstance(content, unicode): return content try: return content.encode('utf-8') except Exception: return content def parse_log_content(content, prefix='', **kwargs): if content is None: return '', prefix - content = obj2log_simple(content) + if not isinstance(content, str) and not isinstance(content, unicode): content = pf(content, pf_replace_newline=False, pf_encode_text=False) if len(content) == 0: content = '{EMPTY STRING}' if content[0] == "!": content = content[1:]; prefix = '\n' - return content, prefix + return content, prefix def process_log_content(content, prefix='', timestamp=None, do_encode=True, **kwargs): content = pad_lines_regex(content, timestamp=timestamp, **kwargs) st = '[%s]:\t' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' return prefix + ' ' + st + (encode_log_text(content, **kwargs) if do_encode else content), content -def crosspost_log(content, filename=None, crosspost_to_default=False, crosspost=None, **kwargs): +def crosspost_log(content, filename=None, crosspost_to_default=False, crosspost=None, do_show_tooltip=False, **kwargs): if crosspost_to_default and filename: summary = " ** %s%s: " % ('' if filename.upper() == 'ERROR' else 'CROSS-POST TO ', filename.upper()) + content log(summary[:200], **kwargs) + if do_show_tooltip: show_tooltip(content) if not crosspost: return for fn in item_to_list(crosspost): log(content, fn, **kwargs) @@ -469,27 +480,43 @@ def log(content=None, filename=None, **kwargs): kwargs = set_kwargs(kwargs, 'line_padding, line_padding_plus, line_padding_header', timestamp=True) content, prefix = parse_log_content(content, **kwargs) crosspost_log(content, filename, **kwargs) - full_path = get_log_full_path(filename, **kwargs) + full_path = get_log_full_path(filename, **kwargs) + if full_path is False: return content, print_content = process_log_content(content, prefix, **kwargs) write_file_contents(content, full_path, print_content=print_content, **kwargs) -def log_sql(content, **kwargs): - log(content, 'sql', **kwargs) +def log_sql(content, a=None, kw=None, **kwargs): + table = content.replace('`', '').replace(',', '') + i = table.find("FROM") + table = table[i+4:].strip() + table = table[:table.find(' ')] + if a or kw: + content = u"SQL: %s" % content + if a: content += u"\n\nArgs: " + pf(a, pf_encode_text=False, pf_decode_text=True) + if kw: content += u"\n\nKwargs: " + pf(kw, pf_encode_text=False, pf_decode_text=True) + log(content, 'sql\\'+table, **kwargs) def log_error(content, **kwargs): kwargs = set_kwargs(kwargs, ['crosspost_to_default', True]) log(content, 'error', **kwargs) -def print_dump(obj): +def pf(obj, title='', pf_replace_newline=True, pf_encode_text=True, pf_decode_text=False): content = pprint.pformat(obj, indent=4, width=ANKNOTES.FORMATTING.PPRINT_WIDTH) content = content.replace(', ', ', \n ') - content = content.replace('\r', '\r ').replace('\n', - '\n ') - content = encode_log_text(content) + if pf_replace_newline: content = content.replace('\r', '\r' + ' ' * 30).replace('\n','\n' + ' ' * 30) + if pf_encode_text: content = encode_log_text(content) + elif pf_decode_text: content = unicode(content, 'utf-8', 'ignore') + if title: content=title + ": " + content + return content + +def print_dump(): + content = pf(*args,**kwargs) print content return content -def log_dump(obj, title="Object", filename='', timestamp=True, extension='log', crosspost_to_default=True, **kwargs): +pp=print_dump + +def log_dump(obj, title="Object", filename='', crosspost_to_default=True, **kwargs): content = pprint.pformat(obj, indent=4, width=ANKNOTES.FORMATTING.PPRINT_WIDTH) try: content = content.decode('utf-8', 'ignore') except Exception: pass @@ -498,11 +525,11 @@ def log_dump(obj, title="Object", filename='', timestamp=True, extension='log', summary = " ** CROSS-POST TO %s: " % filename[1:] + content log(summary[:200]) # filename = 'dump' + ('-%s' % filename if filename else '') - full_path = get_log_full_path(filename, extension, prefix='dump') - st = '[%s]: ' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' - + full_path = get_log_full_path(filename, prefix='dump', **kwargs) + if full_path is False: return + if not title: title = "<%s>" % obj.__class__.__name__ if title[0] == '-': crosspost_to_default = False; title = title[1:] - prefix = " **** Dumping %s" % title + prefix = " **** Dumping %s" % title + " to " + os.path.splitext(os.path.basename(full_path))[0].replace('dump-', '') if crosspost_to_default: log(prefix) content = encode_log_text(content) @@ -519,8 +546,9 @@ def log_dump(obj, title="Object", filename='', timestamp=True, extension='log', os.makedirs(os.path.dirname(full_path)) try_print(full_path, content, prefix, **kwargs) -def try_print(full_path, content, prefix='', line_prefix=u'\n ', attempt=0, clear=False): +def try_print(full_path, content, prefix='', line_prefix=u'\n ', attempt=0, clear=False, timestamp=True, **kwargs): try: + st = '[%s]: ' % datetime.now().strftime(ANKNOTES.DATE_FORMAT) if timestamp else '' print_content = line_prefix + (u' <%d>' % attempt if attempt > 0 else u'') + u' ' + st if attempt is 0: print_content += content elif attempt is 1: print_content += content.decode('utf-8') @@ -532,8 +560,9 @@ def try_print(full_path, content, prefix='', line_prefix=u'\n ', attempt=0, clea elif attempt is 7: print_content += "Unable to print content." with open(full_path, 'w+' if clear else 'a+') as fileLog: print>> fileLog, print_content - except: + except Exception, e: if attempt < 8: try_print(full_path, content, prefix=prefix, line_prefix=line_prefix, attempt=attempt+1, clear=clear) + else: log("Try print error to %s: %s" % (os.path.split(full_path)[1], str(e))) def log_api(method, content='', **kwargs): if content: content = ': ' + content diff --git a/anknotes/menu.py b/anknotes/menu.py index acc8b8e..4177f1b 100644 --- a/anknotes/menu.py +++ b/anknotes/menu.py @@ -6,7 +6,6 @@ from pysqlite2 import dbapi2 as sqlite except ImportError: from sqlite3 import dbapi2 as sqlite - # Anknotes Shared Imports from anknotes.shared import * from anknotes.constants import * @@ -43,25 +42,25 @@ def anknotes_setup_menu(): [ ["Complete All &Steps", see_also], ["SEPARATOR", None], - ["Step &1: Process Anki Notes Without See Also Field", lambda: see_also(1)], - ["Step &2: Extract Links from TOC", lambda: see_also(2)], + ["Step &1: Process Anki Notes Without See Also Field", lambda: see_also(1)], ["SEPARATOR", None], - ["Step &3: Create Auto TOC Evernote Notes", lambda: see_also(3)], - ["Step &4: Validate and Upload Auto TOC Notes", lambda: see_also(4)], - ["Step &5: Rebuild TOC/Outline Link Database", lambda: see_also(5)], + ["Step &2: Create Auto TOC Evernote Notes", lambda: see_also(2)], + ["Step &3: Validate and Upload Auto TOC Notes", lambda: see_also(3)], + ["Step &4: Extract Links from TOC Notes", lambda: see_also(4)], ["SEPARATOR", None], - ["Step &6: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(6)], - ["Step &7: Update See Also Footer In Evernote Notes", lambda: see_also(7)], - ["Step &8: Validate and Upload Modified Evernote Notes", lambda: see_also(8)], + ["Step &5: Insert TOC/Outline Links Into Anki Notes", lambda: see_also(5)], + ["Step &6: Update See Also Footer In Evernote Notes", lambda: see_also(6)], + ["Step &7: Validate and Upload Modified Evernote Notes", lambda: see_also(7)], ["SEPARATOR", None], - ["Step &9: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(9)] + ["Step &8: Insert TOC and Outline Content Into Anki Notes", lambda: see_also(8)] ] ], ["&Maintenance Tasks", [ ["Find &Deleted Notes", find_deleted_notes], ["Res&ync with Local DB", resync_with_local_db], - ["Update Evernote &Ancillary Data", update_ancillary_data] + ["Update Evernote &Ancillary Data", update_ancillary_data], + ["&lxml Test", lxml_test] ] ] @@ -72,11 +71,14 @@ def anknotes_setup_menu(): def auto_reload_wrapper(function): return lambda: auto_reload_modules(function) -def auto_reload_modules(function): +def auto_reload_modules(function): if ANKNOTES.DEVELOPER_MODE.ENABLED and ANKNOTES.DEVELOPER_MODE.AUTO_RELOAD_MODULES: + log_banner('AUTO RELOAD MODULES - RELOADING', 'automation', claar=True) anknotes.shared = reload(anknotes.shared) if not anknotes.Controller: importlib.import_module('anknotes.Controller') reload(anknotes.Controller) + else: + log_banner('AUTO RELOAD MODULES - SKIPPING RELOAD', 'automation', clear=True) function() def add_menu_items(menu_items, parent=None): @@ -143,8 +145,97 @@ def import_from_evernote(auto_page_callback=None): controller.currentPage = mw.col.conf.get(SETTINGS.EVERNOTE.PAGINATION_CURRENT_PAGE, 1) controller.proceed() - -def upload_validated_notes(automated=False): +def create_subnotes(guids): + def create_subnote(guid): + def process_lists(lst, names=None, levels=None, under_ul=False): + def add_note(contents, names, levels): + l.go("NOTE:".ljust(16) + '%-6s %-20s %s' % ('.'.join(map(str, levels)) + ':', ': '.join(names), contents), 'notes', crosspost=('.'.join(map(str, levels)) + ' - ' + '-'.join(names))) + myNotes.append([levels, names, contents]) + def process_list_item(contents, under_ul=False): + list_items_full =[] + sublist = None + for li in contents: + if not isinstance(li, Tag): list_items_full.append(unicode(li)); continue + if not under_ul and (li.name == "ol" or (li.name == "ul" and len(li.contents) > 2)): sublist = li; break + list_items_full.append(unicode(li)) + return sublist, ''.join(list_items_full) + if levels is None or names is None: levels = []; names = [title] + level = len(levels) + for lst_items in lst: + if isinstance(lst_items, Tag): + full_text = unicode(str(lst_items.contents), 'utf-8') + if len(lst_items.contents) is 0: + l.go('NO TOP TEXT:'.ljust(16) + '%s%s: %s' % ('\t' * level, '.'.join(map(str, levels)), full_text), crosspost=['notoptext']) + top_text = "N/A" + else: top_text = unicode(str(lst_items.contents[0]), 'utf-8') #strip_tags(str(lst_items.contents[0])).strip() + if lst_items.name == 'ol' or lst_items.name == 'ul': + # levels[-1] += 1 + new_levels = levels[:] + new_levels.append(0) + new_names = names[:] + new_names.append('CHILD ' + lst_items.name.upper()) + tag_names={'ul': 'UNORDERED LIST', 'ol': 'ORDERED LIST'} + l.go((tag_names[lst_items.name] + ':').ljust(16) + '[%d] %s: <%s>' % (len(levels), '.'.join(map(str, levels)), '')) + process_lists(lst_items.contents, new_names, new_levels, under_ul or lst_items.name == 'ul') + elif lst_items.name == 'li': + levels[-1] += 1 + top_text = strip_tags(top_text, True).strip() + sublist, list_items_full = process_list_item(lst_items.contents, under_ul) + sublist_html = unicode(sublist) + list_items_full_txt = strip_tags(list_items_full, True).strip() + if sublist: + names[-1] = list_items_full_txt + subnote_fn='subnotes*\\' + '.'.join(map(str, levels)) + subnote_shared='*\\..\\subnotes-all' + l.banner(': '.join(names), subnote_fn, clear=True) + if not create_subnote.logged_subnote: + l.blank(subnote_shared) + l.banner(title, subnote_shared, clear=False, append_newline=False) + l.banner(title, 'subnotes', clear=True) + create_subnote.logged_subnote=True + sub_txt = '%s%s: %s' % ('\t' * level, '.'.join(map(str, levels)), list_items_full_txt) + l.go('SUBLIST:'.ljust(16) + sub_txt) + l.go(sub_txt, 'subnotes', crosspost=[subnote_fn, subnote_shared]) + l.go(sublist_html, subnote_fn) + else: + l.go('LIST ITEM: %s%s: %s' % ('\t' * level, '.'.join(map(str, levels)), list_items_full_txt)) + process_lists(lst_items.contents, names[:], levels[:], under_ul) + else: + l.go('OTHER TAG: %s%s: %s' % ('\t' * level, '.'.join(map(str, levels)), top_text)) + elif isinstance(lst_items, NavigableString): + this_name = unicode(lst_items).strip() + l.go('STRING:'.ljust(16) + '%s%s: %s' % ('\t' * level, '.'.join(map(str, levels)), this_name), crosspost='strings') + else: + l.go('LST ITEMS:'.ljust(16) + lst_items.__class__.__name__, crosspost='*\\..\\unexpected-type') + + content = ankDB().scalar("SELECT content FROM %s WHERE guid = '%s'" % (TABLES.EVERNOTE.NOTES, guid)) + title = note_title = get_evernote_title_from_guid(guid) + l.path_suffix = '\\' + title + soup = BeautifulSoup(content) + lists = soup.find('en-note').find(['ol','ul']) + lists_all = soup.findAll(['ol','ul']) + l.banner(title, clear=True, crosspost='strings') + create_subnote.logged_subnote=False + process_lists([lists]) + # process_lists(lists_all) + l.go(str(lists), filename='lists', clear=True) + l.go(soup.prettify(), filename='full', clear=True) + + + myNotes=[] + if import_lxml() is False: return False + from anknotes.shared import lxml + from BeautifulSoup import BeautifulSoup, NavigableString, Tag + from copy import copy + l = Logger(default_filename='bs4', timestamp=False, rm_path=True) + for guid in guids: create_subnote(guid) + + +def lxml_test(): + guids = ankDB().list("SELECT guid FROM %s WHERE tagNames LIKE '%%,%s,%%' ORDER BY title ASC " % (TABLES.EVERNOTE.NOTES, TAGS.OUTLINE)) + create_subnotes(guids) + +def upload_validated_notes(automated=False, **kwargs): controller = anknotes.Controller.Controller() controller.upload_validated_notes(automated) @@ -165,7 +256,7 @@ def find_deleted_notes(automated=False): # if not automated: # mw.unloadCollection() # else: - # mw.col.close() + # mw.col.close() # handle = Popen(['python',FILES.SCRIPTS.FIND_DELETED_NOTES], stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=True) # stdoutdata, stderrdata = handle.communicate() # err = ("ERROR: {%s}\n\n" % stderrdata) if stderrdata else '' @@ -188,9 +279,7 @@ def find_deleted_notes(automated=False): showInfo(info, richText=True, minWidth=600) db_changed = False if anknotes_dels_count > 0: - code = \ - getText("Please enter code 'ANKNOTES_DEL_%d' to delete your orphan Anknotes DB note(s)" % anknotes_dels_count)[ - 0] + code = getText("Please enter code 'ANKNOTES_DEL_%d' to delete your orphan Anknotes DB note(s)" % anknotes_dels_count)[0] if code == 'ANKNOTES_DEL_%d' % anknotes_dels_count: ankDB().executemany("DELETE FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, [[x] for x in anknotes_dels]) delete_anki_notes_and_cards_by_guid(anknotes_dels) @@ -202,15 +291,15 @@ def find_deleted_notes(automated=False): delete_anki_notes_and_cards_by_guid(anki_dels) db_changed = True show_tooltip("Deleted all %d Orphan Anki Notes" % anki_dels_count, 5000, 6000) - if db_changed: - ankDB().commit() + if db_changed: ankDB().commit() if missing_evernote_notes_count > 0: if showInfo("Would you like to import %d missing Evernote Notes?<BR><BR><a href='%s'>Click to view results</a>" % (missing_evernote_notes_count, convert_filename_to_local_link(get_log_full_path(FILES.LOGS.FDN.UNIMPORTED_EVERNOTE_NOTES))), cancelButton=True, richText=True): import_from_evernote_manual_metadata(missing_evernote_notes) -def validate_pending_notes(showAlerts=True, uploadAfterValidation=True, callback=None): - mw.unloadCollection() +def validate_pending_notes(showAlerts=True, uploadAfterValidation=True, callback=None, unloadedCollection=False, reload_delay=10): + if not unloadedCollection: return unload_collection(lambda *xargs, **xkwargs: validate_pending_notes(showAlerts, uploadAfterValidation, callback(*xargs, **xkwargs), True)) + log("Validating Notes", 'automation') if showAlerts: showInfo("""Press Okay to save and close your Anki collection, open the command-line note validation tool, and then re-open your Anki collection.%s @@ -240,70 +329,133 @@ def validate_pending_notes(showAlerts=True, uploadAfterValidation=True, callback # mw.col.reopen() # mw.col.load() - - if callback is None and allowUpload: - callback = upload_validated_notes - external_tool_callback_timer(callback) - - -def reopen_collection(callback=None): - # mw.setupProfile() + log("Validate Notes completed", 'automation') + if callback is None and allowUpload: callback = lambda *xargs, **xkwargs: upload_validated_notes() + mw.progress.timer(reload_delay * 1000, lambda: reload_collection(callback), False) + +def modify_collection(collection_operation, action_str='modifying collection', callback=None, callback_failure=False, callback_delay=0, delay=30, attempt=1, max_attempts=5, **kwargs): + passed=False + retry = ("Will try again in %ds" % delay + ' (Attempt #%d)' % attempt if attempt > 0 else '') if attempt <= max_attempts else "Max attempts of %d exceeded... Aborting operation" % max_attempts + try: + return_val = collection_operation() + passed=True + except (sqlite.OperationalError, sqlite.ProgrammingError, Exception), e: + if e.message.replace(".", "") == 'database is locked': friendly_message = 'sqlite database is locked' + elif e.message == "Cannot operate on a closed database.": friendly_message = 'sqlite database is closed' + else: + if e.message.replace('.', '') == 'database is locked': log_error('**locked', crosspost='automation', crosspost_to_default=False) + import traceback + type = str(e.__class__); type = type[type.find("'")+1:type.rfind("'")] + friendly_message = ('Unhandled Error' if type == 'Exception' else type) + ':\n Full Error: ' + ' '.join(str(e).split()) + '\n Message: "%s"' % e.message + '\n Trace: ' + traceback.format_exc() + '\n' + log_error(" > Modify Collection: Error %s: %s. %s" % (action_str, retry, friendly_message), time_out=10000, do_show_tooltip=True, crosspost='automation', crosspost_to_default=False) + if not passed: return (False if callback_failure is False else callback(None, **kwargs)) if attempt > max_attempts else mw.progress.timer(delay*1000, lambda: modify_collection(collection_operation, action_str, callback, callback_failure, callback_delay, delay, attempt+1, **kwargs), False) + if not callback: log(" > Modify Collection: Completed %s" % action_str, 'automation'); return + log(" > Modify Collection: Completed %s" % action_str + ': %s Initiated' % ('%ds Callback Timer' % callback_delay if callback_delay > 0 else 'Callback'), 'automation') + if not callback: return #return_val + if callback_delay > 0: mw.progress.timer(callback_delay*1000, lambda: callback(return_val, **kwargs), False); return #return return_val + callback(return_val, **kwargs) + #return return_val + +def reload_collection(callback=None, reopen_delay=0, callback_delay=30, *args, **kwargs): + if not mw.col is None: + try: + myDB = ankDB(True) + db = myDB._db + cur=db.execute("SELECT title FROM %s WHERE 1 ORDER BY RANDOM() LIMIT 1" % TABLES.EVERNOTE.NOTES) + result = cur.fetchone() + log(" > Reload Collection: Not needed: ankDB exists and cursor created: %s" % (str_safe(result[0])), 'automation') + if callback: callback(True) + return True + except (sqlite.ProgrammingError, Exception), e: + if e.message == "Cannot operate on a closed database": + # mw.loadCollection() + log(" > Reloading Collection Check: DB is Closed. Proceed with reload. Col: " + str(mw.col), 'automation') + else: + import traceback + log(" > Reloading Collection Check Failed : " + str(e) + '\n - Trace: ' + traceback.format_exc(), 'automation') + log(" > Initiating Reload: %sInitiated: %s" % ('%ds Timer ' % reopen_delay if reopen_delay > 0 else '', str(mw.col)), 'automation') + # if callback and reopen_callback_delay > 0: + # log("Reload Collection: Callback Timer Set To %ds" % reopen_callback_delay, 'automation') + # callback = lambda: mw.progress.timer(reopen_callback_delay*1000, callback, False) + if reopen_delay > 0: return mw.progress.timer(reopen_delay*1000, lambda *xargs, **xkwargs: modify_collection(do_load_collection, 'reload collection', lambda *xargs, **xkwargs: callback(*args, **kwargs), callback_delay=callback_delay, *args, **kwargs), False) + modify_collection(do_load_collection, 'Reloading Collection', callback, callback_delay=callback_delay, *args, **kwargs) + +def do_load_collection(): + log(" > Do Load Collection: Attempting mw.loadCollection()", 'automation') mw.loadCollection() + log(" > Do Load Collection: Attempting ankDB(True)", 'automation') ankDB(True) - if callback: callback() - - -def external_tool_callback_timer(callback=None): - mw.progress.timer(3000, lambda: reopen_collection(callback), False) - - -def see_also(steps=None, showAlerts=None, validationComplete=False): - controller = anknotes.Controller.Controller() + +def do_unload_collection(): + mw.unloadCollection() + +def unload_collection(*args, **kwargs): + log("Initiating Unload Collection:", 'automation') + modify_collection(mw.unloadCollection, 'Unload Collection', *args, **kwargs) + + +def load_controller(callback=None, callback_failure=True, *args, **kwargs): + # log('Col: ' + str(mw.col), 'automation') + # log('Col db: ' + str(mw.col.db), 'automation') + modify_collection(anknotes.Controller.Controller, 'Loading Controller', callback, callback_failure=callback_failure) + # return anknotes.Controller.Controller() + +def see_also(steps=None, showAlerts=None, validationComplete=False, controller=None): + if controller is None: + check = reload_collection() + if check: + log("See Also --> 2. Loading Controller", 'automation') + callback = lambda x, *xargs, **xkwargs: see_also(steps, showAlerts, validationComplete, x) + load_controller(callback) + else: + log("See Also --> 1. Loading Collection", 'automation') + callback = lambda *xargs, **xkwargs: see_also(steps, showAlerts, validationComplete) + reload_collection(callback) + return False if not steps: steps = range(1, 10) if isinstance(steps, int): steps = [steps] + steps = list(steps) + log("See Also --> 3. Proceeding: " + ', '.join(map(str, steps)), 'automation') multipleSteps = (len(steps) > 1) if showAlerts is None: showAlerts = not multipleSteps remaining_steps=steps if 1 in steps: # Should be unnecessary once See Also algorithms are finalized - log(" > See Also: Step 1: Process Un Added See Also Notes") + log(" > See Also: Step 1: Process Un Added See Also Notes", crosspost='automation') controller.process_unadded_see_also_notes() if 2 in steps: - log(" > See Also: Step 2: Extract Links from TOC") - controller.anki.extract_links_from_toc() - if 3 in steps: - log(" > See Also: Step 3: Create Auto TOC Evernote Notes") + log(" > See Also: Step 2: Create Auto TOC Evernote Notes", crosspost='automation') controller.create_auto_toc() - if 4 in steps: + if 3 in steps: if validationComplete: - log(" > See Also: Step 4B: Validate and Upload Auto TOC Notes: Upload Validated Notes") + log(" > See Also: Step 3B: Validate and Upload Auto TOC Notes: Upload Validated Notes", crosspost='automation') upload_validated_notes(multipleSteps) validationComplete = False - else: steps = [-4] - if 5 in steps: - log(" > See Also: Step 5: Rebuild TOC/Outline Link Database") + else: steps = [-3] + if 4 in steps: + log(" > See Also: Step 4: Extract Links from TOC", crosspost='automation') controller.anki.extract_links_from_toc() - if 6 in steps: - log(" > See Also: Step 6: Insert TOC/Outline Links Into Anki Notes' See Also Field") + if 5 in steps: + log(" > See Also: Step 5: Insert TOC/Outline Links Into Anki Notes' See Also Field", crosspost='automation') controller.anki.insert_toc_into_see_also() - if 7 in steps: - log(" > See Also: Step 7: Update See Also Footer In Evernote Notes") + if 6 in steps: + log(" > See Also: Step 6: Update See Also Footer In Evernote Notes", crosspost='automation') from anknotes import detect_see_also_changes detect_see_also_changes.main() - if 8 in steps: + if 7 in steps: if validationComplete: - log(" > See Also: Step 8B: Validate and Upload Modified Evernote Notes: Upload Validated Notes") + log(" > See Also: Step 7B: Validate and Upload Modified Evernote Notes: Upload Validated Notes", crosspost='automation') upload_validated_notes(multipleSteps) - else: steps = [-8] - if 9 in steps: - log(" > See Also: Step 9: Insert TOC/Outline Contents Into Anki Notes") + else: steps = [-7] + if 8 in steps: + log(" > See Also: Step 8: Insert TOC/Outline Contents Into Anki Notes", crosspost='automation') controller.anki.insert_toc_and_outline_contents_into_notes() do_validation = steps[0]*-1 if do_validation>0: - log(" > See Also: Step %dA: Validate and Upload %s Notes: Validate Notes" % (do_validation, {4: 'Auto TOC', 8: 'Modified Evernote'}[do_validation])) + log(" > See Also: Step %dA: Validate and Upload %s Notes: Validate Notes" % (do_validation, {3: 'Auto TOC', 7: 'Modified Evernote'}[do_validation]), crosspost='automation') remaining_steps = remaining_steps[remaining_steps.index(do_validation):] - validate_pending_notes(showAlerts, callback=lambda: see_also(remaining_steps, False, True)) + validate_pending_notes(showAlerts, callback=lambda *xargs, **xkwargs: see_also(remaining_steps, False, True)) def update_ancillary_data(): controller = anknotes.Controller.Controller() diff --git a/anknotes/settings.py b/anknotes/settings.py index 9fbadbf..ed724e8 100644 --- a/anknotes/settings.py +++ b/anknotes/settings.py @@ -1,9 +1,17 @@ # -*- coding: utf-8 -*- +import sys +inAnki = 'anki' in sys.modules + ### Anknotes Shared Imports from anknotes.shared import * from anknotes.graphics import * +### Import Anki + + + + ### Anki Imports try: import anki @@ -16,6 +24,7 @@ QMessageBox, QPixmap from aqt import mw except: + import pdb; import traceback; print traceback.format_exc(); pdb.set_trace() pass diff --git a/anknotes/shared.py b/anknotes/shared.py index 47d3c47..10b2632 100644 --- a/anknotes/shared.py +++ b/anknotes/shared.py @@ -1,28 +1,29 @@ # -*- coding: utf-8 -*- ### Python Imports -try: - from pysqlite2 import dbapi2 as sqlite -except ImportError: - from sqlite3 import dbapi2 as sqlite +try: from pysqlite2 import dbapi2 as sqlite +except ImportError: from sqlite3 import dbapi2 as sqlite import os import re import sys +from bs4 import UnicodeDammit + ### Check if in Anki inAnki='anki' in sys.modules + ### Anknotes Imports +from anknotes.imports import * from anknotes.constants import * from anknotes.logging import * +from anknotes.db import * from anknotes.html import * from anknotes.structs import * -from anknotes.db import * - -### Anki and Evernote Imports -if inAnki: - from aqt import mw - from aqt.qt import QIcon, QPixmap, QPushButton, QMessageBox - from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ - EDAMNotFoundException +# if inAnki: +from aqt import mw +from aqt.qt import QIcon, QPixmap, QPushButton, QMessageBox +from anknotes.evernote.edam.error.ttypes import EDAMSystemException, EDAMErrorCode, EDAMUserException, \ + EDAMNotFoundException + def get_friendly_interval_string(lastImport): if not lastImport: return "" td = (datetime.now() - datetime.strptime(lastImport, ANKNOTES.DATE_FORMAT)) @@ -95,6 +96,29 @@ def find_evernote_links(content): ids.update(match.group('uid'), match.group('shard')) return [EvernoteLink(m) for m in re.finditer(regex_str, content)] +def check_evernote_guid_is_valid(guid): + return ankDB().scalar("SELECT COUNT(*) FROM %s WHERE guid = ?" % TABLES.EVERNOTE.NOTES, guid) + +def escape_regex(strr): return re.sub(r"(?sx)(\(|\||\))", r"\\\1", strr) + +def remove_evernote_link(link, html): + html = UnicodeDammit(html, ['utf-8'], is_html=True).unicode_markup + link_converted = UnicodeDammit(link.WholeRegexMatch, ['utf-8'], is_html=True).unicode_markup + sep = u'<span style="color: rgb(105, 170, 53);"> | </span>' + sep_regex = escape_regex(sep) + regex_replace = r'<{0}[^>]*>[^<]*{1}[^<]*</{0}>' + # html = re.sub(regex_replace.format('li', link.WholeRegexMatch), "", html) + # Remove link + html = html.replace(link.WholeRegexMatch, "") + # Remove empty li + html = re.sub(regex_replace.format('li', '[^<]*'), "", html) + # Remove dangling separator + regex_span = regex_replace.format('span', r'[^<]*') + r'[^<]*' + sep_regex + html = re.sub(regex_span, "", html) + # Remove double separator + html = re.sub(sep_regex + r'[^<]*' + sep_regex, sep_regex, html) + return html + def get_dict_from_list(lst, keys_to_ignore=list()): dic = {} for key, value in lst: diff --git a/anknotes/stopwatch/__init__.py b/anknotes/stopwatch/__init__.py index 2270fdb..72521ea 100644 --- a/anknotes/stopwatch/__init__.py +++ b/anknotes/stopwatch/__init__.py @@ -166,7 +166,7 @@ def displayInitialInfo(self,max=None,interval=None, automated=None, enabled=None return self.setStatus(EvernoteAPIStatus.EmptyRequest) if not self.enabled: log("Not starting - stopwatch.ActionInfo = false ", self.Label) - if not automated: showReport(self.ActionLine("Aborted", "Action has been disabled"), blank_line_before=False) + if not automated: show_report(self.ActionLine("Aborted", "Action has been disabled"), blank_line_before=False) return self.setStatus(EvernoteAPIStatus.Disabled) log (self.Initiated) if self.willReportProgress: @@ -230,6 +230,12 @@ def counts(self, value): def laps(self): return len(self.__times) + @property + def max(self): return self.counts.max.val + + @max.setter + def max(self, value): self.counts.max = value; self.counts.max_allowed = value if self.counts.max_allowed < 1 else self.counts.max_allowed + @property def is_success(self): return self.counts.success @@ -431,10 +437,11 @@ def Report(self, subcount_created=0, subcount_updated=0): log_blank(filename='counters') log(self.counts.fullSummary((self.label if self.label else 'Counter') + ': End'), 'counters') - def step(self, title=None, val=None): - if val is None and (isinstance(title, str) or isinstance(title, unicode)) and title.isdigit(): - val = title - title = None + def increment(self, title=None, **kwargs): + self.counts.step(**kwargs) + return self.step(title=title, **kwargs) + + def step(self, title=None, **kwargs): if self.hasActionInfo and self.isProgressCheck and title: fstr = (" %"+str(len('#'+str(self.counts.max.val)))+"s: %s: %s") log( self.info.ActionShortSingle + (" %"+str(len('#'+str(self.counts.max.val)))+"s: %s: %s") % ('#' + str(self.count), self.progress, title), self.label) @@ -460,15 +467,14 @@ def automated(self): return self.info.Automated def hasActionInfo(self): - return self.info is not None and self.counts.max + return self.info and self.counts.max def __init__(self, max=None, interval=100, info=None, infoStr=None, automated=None, enabled=None, begin=True, label=None, display_initial_info=None, max_allowed=None): """ :type info : ActionInfo """ simple_label = False - self.counts = EvernoteCounter() - self.counts.max_allowed = -1 if max_allowed is None else max_allowed + self.counts = EvernoteCounter() self.__interval = interval if type(info) == str or type(info) == unicode: info = ActionInfo(info) if infoStr and not info: info = ActionInfo(infoStr) @@ -479,6 +485,7 @@ def __init__(self, max=None, interval=100, info=None, infoStr=None, automated=No elif label: info.__label = label if max is not None and info and (info.Max is None or info.Max <= 0): info.Max = max self.counts.max = -1 if max is None else max + self.counts.max_allowed = self.counts.max if max_allowed is None else max_allowed self.__did_break = True self.__info = info self.__action_initialized = False @@ -579,6 +586,11 @@ def __time(self): """ return time.time() + @property + def str_long(self): + return self.__timetostr__(short=False) + + def __timetostr__(self, total_seconds=None, short = True, pad=True): if total_seconds is None: total_seconds=self.elapsed total_seconds = int(round(total_seconds)) @@ -595,6 +607,8 @@ def __str__(self): """ return self.__timetostr__() + def __repr__(self): + return "<%s%s> %s" % (self.__class__.__name__, '' if not self.label else ':%s' % self.label, self.str_long) all_clockit_timers = {} def clockit(func): diff --git a/anknotes/structs.py b/anknotes/structs.py index fb8ea3a..efe4cc4 100644 --- a/anknotes/structs.py +++ b/anknotes/structs.py @@ -1,9 +1,11 @@ import re +# from BeautifulSoup import UnicodeDammit import anknotes +from bs4 import UnicodeDammit from anknotes.db import * from anknotes.enum import Enum from anknotes.html import strip_tags -from anknotes.logging import PadList, JoinList +from anknotes.logging import PadList, JoinList, item_to_set, item_to_list from anknotes.enums import * from anknotes.EvernoteNoteTitle import EvernoteNoteTitle @@ -27,7 +29,8 @@ class EvernoteStruct(object): __sql_columns__ = "name" __sql_table__ = TABLES.EVERNOTE.TAGS __sql_where__ = "guid" - __attr_order__ = [] + __attr_order__ = None + __additional__attr__ = None __title_is_note_title = False @staticmethod @@ -99,8 +102,12 @@ def setFromKeyedObject(self, keyed_object, keys=None): lst = self._valid_attributes_() if keys or isinstance(keyed_object, dict): pass - elif isinstance(keyed_object, type(re.search('', ''))): - keyed_object = keyed_object.groupdict() + elif isinstance(keyed_object, type(re.search('', ''))): + regex_attr = 'wholeRegexMatch' + self.__additional__attr__.add(regex_attr) + whole_match = keyed_object.group(0) + keyed_object = keyed_object.groupdict() + keyed_object[regex_attr] = whole_match elif hasattr(keyed_object, 'keys'): keys = getattrcallable(keyed_object, 'keys') elif hasattr(keyed_object, self.__sql_where__): @@ -126,15 +133,17 @@ def setFromListByDefaultOrder(self, args): self.setAttribute(self.__attr_order__[i], value) def _valid_attributes_(self): - return set().union(self.__sql_columns__, [self.__sql_where__], self.__attr_order__) + return self.__additional__attr__.union(self.__sql_columns__, [self.__sql_where__], self.__attr_order__) def _is_valid_attribute_(self, attribute): return (attribute[0].lower() + attribute[1:]) in self._valid_attributes_() def __init__(self, *args, **kwargs): - if isinstance(self.__sql_columns__, str): self.__sql_columns__ = [self.__sql_columns__] - if isinstance(self.__attr_order__, str) or isinstance(self.__attr_order__, unicode): - self.__attr_order__ = self.__attr_order__.replace('|', ' ').split(' ') + if self.__attr_order__ is None: self.__attr_order__ = [] + if self.__additional__attr__ is None: self.__additional__attr__ = set() + self.__sql_columns__ = item_to_list(self.__sql_columns__, chrs=' ,;') + self.__attr_order__ = item_to_list(self.__attr_order__, chrs=' ,;') + self.__additional__attr__ = item_to_set(self.__additional__attr__, chrs=' ,;') args = list(args) if args and self.setFromKeyedObject(args[0]): del args[0] self.setFromListByDefaultOrder(args) @@ -157,11 +166,16 @@ class EvernoteTag(EvernoteStruct): class EvernoteLink(EvernoteStruct): __uid__ = -1 Shard = 'x999' - Guid = "" + Guid = "" + WholeRegexMatch = "" __title__ = None """:type: EvernoteNoteTitle.EvernoteNoteTitle """ __attr_order__ = 'uid|shard|guid|title' + + def __init__(self, *args, **kwargs): + return super(self.__class__, self).__init__(*args, **kwargs) + @property def HTML(self): return self.Title.HTML @@ -191,6 +205,19 @@ def Uid(self): @Uid.setter def Uid(self, value): self.__uid__ = int(value) + + @property + def NoteTitle(self): + f = anknotes.EvernoteNoteFetcher.EvernoteNoteFetcher(guid=self.Guid, use_local_db_only=True) + if not f.getNote(): return "<Invalid Note>" + return f.result.Note.FullTitle + + def __str__(self): + return "<%s> %s: %s" % (self.__class__.__name__, self.Guid, self.FullTitle) + + def __repr__(self): + # id = + return "<%s> %s: %s" % (self.__class__.__name__, self.Guid, self.NoteTitle) class EvernoteTOCEntry(EvernoteStruct): RealTitle = "" @@ -218,12 +245,13 @@ class EvernoteValidationEntry(EvernoteStruct): TagNames = "" """:type : str""" NotebookGuid = "" + NoteType="" def __init__(self, *args, **kwargs): # spr = super(self.__class__ , self) # spr.__attr_order__ = self.__attr_order__ # spr.__init__(*args, **kwargs) - self.__attr_order__ = 'guid|title|contents|tagNames|notebookGuid' + self.__attr_order__ = 'guid|title|contents|tagNames|notebookGuid|noteType' super(self.__class__, self).__init__(*args, **kwargs) diff --git a/anknotes_start.py b/anknotes_start.py index d596053..8925faa 100644 --- a/anknotes_start.py +++ b/anknotes_start.py @@ -1 +1,3 @@ -from anknotes import __main__ \ No newline at end of file +# from anknotes import bs4 +from anknotes import __main__ +# D:\Scripts\Python\start-ipython.py "D:\Dropbox\Programming\Python\anknotes\anknotes_start.py" \ No newline at end of file diff --git a/anknotes_start_note_validation.py b/anknotes_start_note_validation.py index 9e2806c..8d6d51f 100644 --- a/anknotes_start_note_validation.py +++ b/anknotes_start_note_validation.py @@ -1,17 +1,14 @@ import os from anknotes import stopwatch +from anknotes.shared import import_etree import time -try: - from lxml import etree - eTreeImported=True -except: - eTreeImported=False -if eTreeImported: +if import_etree(): try: from pysqlite2 import dbapi2 as sqlite except ImportError: from sqlite3 import dbapi2 as sqlite + from anknotes.shared import lxml, etree ### Anknotes Module Imports for Stand Alone Scripts from anknotes import evernote as evernote diff --git a/bs4/__init__.py b/bs4/__init__.py new file mode 100644 index 0000000..af8c718 --- /dev/null +++ b/bs4/__init__.py @@ -0,0 +1,355 @@ +"""Beautiful Soup +Elixir and Tonic +"The Screen-Scraper's Friend" +http://www.crummy.com/software/BeautifulSoup/ + +Beautiful Soup uses a pluggable XML or HTML parser to parse a +(possibly invalid) document into a tree representation. Beautiful Soup +provides provides methods and Pythonic idioms that make it easy to +navigate, search, and modify the parse tree. + +Beautiful Soup works with Python 2.6 and up. It works better if lxml +and/or html5lib is installed. + +For more than you ever wanted to know about Beautiful Soup, see the +documentation: +http://www.crummy.com/software/BeautifulSoup/bs4/doc/ +""" + +__author__ = "Leonard Richardson (leonardr@segfault.org)" +__version__ = "4.1.0" +__copyright__ = "Copyright (c) 2004-2012 Leonard Richardson" +__license__ = "MIT" + +__all__ = ['BeautifulSoup'] + +import re +import warnings + +from .builder import builder_registry +from .dammit import UnicodeDammit +from .element import ( + CData, + Comment, + DEFAULT_OUTPUT_ENCODING, + Declaration, + Doctype, + NavigableString, + PageElement, + ProcessingInstruction, + ResultSet, + SoupStrainer, + Tag, + ) + +# The very first thing we do is give a useful error if someone is +# running this code under Python 3 without converting it. +syntax_error = u'You are trying to run the Python 2 version of Beautiful Soup under Python 3. This will not work. You need to convert the code, either by installing it (`python setup.py install`) or by running 2to3 (`2to3 -w bs4`).' + +class BeautifulSoup(Tag): + """ + This class defines the basic interface called by the tree builders. + + These methods will be called by the parser: + reset() + feed(markup) + + The tree builder may call these methods from its feed() implementation: + handle_starttag(name, attrs) # See note about return value + handle_endtag(name) + handle_data(data) # Appends to the current data node + endData(containerClass=NavigableString) # Ends the current data node + + No matter how complicated the underlying parser is, you should be + able to build a tree using 'start tag' events, 'end tag' events, + 'data' events, and "done with data" events. + + If you encounter an empty-element tag (aka a self-closing tag, + like HTML's <br> tag), call handle_starttag and then + handle_endtag. + """ + ROOT_TAG_NAME = u'[document]' + + # If the end-user gives no indication which tree builder they + # want, look for one with these features. + DEFAULT_BUILDER_FEATURES = ['html', 'fast'] + + # Used when determining whether a text node is all whitespace and + # can be replaced with a single space. A text node that contains + # fancy Unicode spaces (usually non-breaking) should be left + # alone. + STRIP_ASCII_SPACES = {9: None, 10: None, 12: None, 13: None, 32: None, } + + def __init__(self, markup="", features=None, builder=None, + parse_only=None, from_encoding=None, **kwargs): + """The Soup object is initialized as the 'root tag', and the + provided markup (which can be a string or a file-like object) + is fed into the underlying parser.""" + + if 'convertEntities' in kwargs: + warnings.warn( + "BS4 does not respect the convertEntities argument to the " + "BeautifulSoup constructor. Entities are always converted " + "to Unicode characters.") + + if 'markupMassage' in kwargs: + del kwargs['markupMassage'] + warnings.warn( + "BS4 does not respect the markupMassage argument to the " + "BeautifulSoup constructor. The tree builder is responsible " + "for any necessary markup massage.") + + if 'smartQuotesTo' in kwargs: + del kwargs['smartQuotesTo'] + warnings.warn( + "BS4 does not respect the smartQuotesTo argument to the " + "BeautifulSoup constructor. Smart quotes are always converted " + "to Unicode characters.") + + if 'selfClosingTags' in kwargs: + del kwargs['selfClosingTags'] + warnings.warn( + "BS4 does not respect the selfClosingTags argument to the " + "BeautifulSoup constructor. The tree builder is responsible " + "for understanding self-closing tags.") + + if 'isHTML' in kwargs: + del kwargs['isHTML'] + warnings.warn( + "BS4 does not respect the isHTML argument to the " + "BeautifulSoup constructor. You can pass in features='html' " + "or features='xml' to get a builder capable of handling " + "one or the other.") + + def deprecated_argument(old_name, new_name): + if old_name in kwargs: + warnings.warn( + 'The "%s" argument to the BeautifulSoup constructor ' + 'has been renamed to "%s."' % (old_name, new_name)) + value = kwargs[old_name] + del kwargs[old_name] + return value + return None + + parse_only = parse_only or deprecated_argument( + "parseOnlyThese", "parse_only") + + from_encoding = from_encoding or deprecated_argument( + "fromEncoding", "from_encoding") + + if len(kwargs) > 0: + arg = kwargs.keys().pop() + raise TypeError( + "__init__() got an unexpected keyword argument '%s'" % arg) + + if builder is None: + if isinstance(features, basestring): + features = [features] + if features is None or len(features) == 0: + features = self.DEFAULT_BUILDER_FEATURES + builder_class = builder_registry.lookup(*features) + if builder_class is None: + raise ValueError( + "Couldn't find a tree builder with the features you " + "requested: %s. Do you need to install a parser library?" + % ",".join(features)) + builder = builder_class() + self.builder = builder + self.is_xml = builder.is_xml + self.builder.soup = self + + self.parse_only = parse_only + + self.reset() + + if hasattr(markup, 'read'): # It's a file-type object. + markup = markup.read() + (self.markup, self.original_encoding, self.declared_html_encoding, + self.contains_replacement_characters) = ( + self.builder.prepare_markup(markup, from_encoding)) + + try: + self._feed() + except StopParsing: + pass + + # Clear out the markup and remove the builder's circular + # reference to this object. + self.markup = None + self.builder.soup = None + + def _feed(self): + # Convert the document to Unicode. + self.builder.reset() + + self.builder.feed(self.markup) + # Close out any unfinished strings and close all the open tags. + self.endData() + while self.currentTag.name != self.ROOT_TAG_NAME: + self.popTag() + + def reset(self): + Tag.__init__(self, self, self.builder, self.ROOT_TAG_NAME) + self.hidden = 1 + self.builder.reset() + self.currentData = [] + self.currentTag = None + self.tagStack = [] + self.pushTag(self) + + def new_tag(self, name, namespace=None, nsprefix=None, **attrs): + """Create a new tag associated with this soup.""" + return Tag(None, self.builder, name, namespace, nsprefix, attrs) + + def new_string(self, s): + """Create a new NavigableString associated with this soup.""" + navigable = NavigableString(s) + navigable.setup() + return navigable + + def insert_before(self, successor): + raise ValueError("BeautifulSoup objects don't support insert_before().") + + def insert_after(self, successor): + raise ValueError("BeautifulSoup objects don't support insert_after().") + + def popTag(self): + tag = self.tagStack.pop() + #print "Pop", tag.name + if self.tagStack: + self.currentTag = self.tagStack[-1] + return self.currentTag + + def pushTag(self, tag): + #print "Push", tag.name + if self.currentTag: + self.currentTag.contents.append(tag) + self.tagStack.append(tag) + self.currentTag = self.tagStack[-1] + + def endData(self, containerClass=NavigableString): + if self.currentData: + currentData = u''.join(self.currentData) + if (currentData.translate(self.STRIP_ASCII_SPACES) == '' and + not set([tag.name for tag in self.tagStack]).intersection( + self.builder.preserve_whitespace_tags)): + if '\n' in currentData: + currentData = '\n' + else: + currentData = ' ' + self.currentData = [] + if self.parse_only and len(self.tagStack) <= 1 and \ + (not self.parse_only.text or \ + not self.parse_only.search(currentData)): + return + o = containerClass(currentData) + self.object_was_parsed(o) + + def object_was_parsed(self, o): + """Add an object to the parse tree.""" + o.setup(self.currentTag, self.previous_element) + if self.previous_element: + self.previous_element.next_element = o + self.previous_element = o + self.currentTag.contents.append(o) + + def _popToTag(self, name, nsprefix=None, inclusivePop=True): + """Pops the tag stack up to and including the most recent + instance of the given tag. If inclusivePop is false, pops the tag + stack up to but *not* including the most recent instqance of + the given tag.""" + #print "Popping to %s" % name + if name == self.ROOT_TAG_NAME: + return + + numPops = 0 + mostRecentTag = None + + for i in range(len(self.tagStack) - 1, 0, -1): + if (name == self.tagStack[i].name + and nsprefix == self.tagStack[i].nsprefix == nsprefix): + numPops = len(self.tagStack) - i + break + if not inclusivePop: + numPops = numPops - 1 + + for i in range(0, numPops): + mostRecentTag = self.popTag() + return mostRecentTag + + def handle_starttag(self, name, namespace, nsprefix, attrs): + """Push a start tag on to the stack. + + If this method returns None, the tag was rejected by the + SoupStrainer. You should proceed as if the tag had not occured + in the document. For instance, if this was a self-closing tag, + don't call handle_endtag. + """ + + # print "Start tag %s: %s" % (name, attrs) + self.endData() + + if (self.parse_only and len(self.tagStack) <= 1 + and (self.parse_only.text + or not self.parse_only.search_tag(name, attrs))): + return None + + tag = Tag(self, self.builder, name, namespace, nsprefix, attrs, + self.currentTag, self.previous_element) + if tag is None: + return tag + if self.previous_element: + self.previous_element.next_element = tag + self.previous_element = tag + self.pushTag(tag) + return tag + + def handle_endtag(self, name, nsprefix=None): + #print "End tag: " + name + self.endData() + self._popToTag(name, nsprefix) + + def handle_data(self, data): + self.currentData.append(data) + + def decode(self, pretty_print=False, + eventual_encoding=DEFAULT_OUTPUT_ENCODING, + formatter="minimal"): + """Returns a string or Unicode representation of this document. + To get Unicode, pass None for encoding.""" + + if self.is_xml: + # Print the XML declaration + encoding_part = '' + if eventual_encoding != None: + encoding_part = ' encoding="%s"' % eventual_encoding + prefix = u'<?xml version="1.0"%s?>\n' % encoding_part + else: + prefix = u'' + if not pretty_print: + indent_level = None + else: + indent_level = 0 + return prefix + super(BeautifulSoup, self).decode( + indent_level, eventual_encoding, formatter) + +class BeautifulStoneSoup(BeautifulSoup): + """Deprecated interface to an XML parser.""" + + def __init__(self, *args, **kwargs): + kwargs['features'] = 'xml' + warnings.warn( + 'The BeautifulStoneSoup class is deprecated. Instead of using ' + 'it, pass features="xml" into the BeautifulSoup constructor.') + super(BeautifulStoneSoup, self).__init__(*args, **kwargs) + + +class StopParsing(Exception): + pass + + +#By default, act as an HTML pretty-printer. +if __name__ == '__main__': + import sys + soup = BeautifulSoup(sys.stdin) + print soup.prettify() diff --git a/bs4/builder/__init__.py b/bs4/builder/__init__.py new file mode 100644 index 0000000..4c22b86 --- /dev/null +++ b/bs4/builder/__init__.py @@ -0,0 +1,307 @@ +from collections import defaultdict +import itertools +import sys +from bs4.element import ( + CharsetMetaAttributeValue, + ContentMetaAttributeValue, + whitespace_re + ) + +__all__ = [ + 'HTMLTreeBuilder', + 'SAXTreeBuilder', + 'TreeBuilder', + 'TreeBuilderRegistry', + ] + +# Some useful features for a TreeBuilder to have. +FAST = 'fast' +PERMISSIVE = 'permissive' +STRICT = 'strict' +XML = 'xml' +HTML = 'html' +HTML_5 = 'html5' + + +class TreeBuilderRegistry(object): + + def __init__(self): + self.builders_for_feature = defaultdict(list) + self.builders = [] + + def register(self, treebuilder_class): + """Register a treebuilder based on its advertised features.""" + for feature in treebuilder_class.features: + self.builders_for_feature[feature].insert(0, treebuilder_class) + self.builders.insert(0, treebuilder_class) + + def lookup(self, *features): + if len(self.builders) == 0: + # There are no builders at all. + return None + + if len(features) == 0: + # They didn't ask for any features. Give them the most + # recently registered builder. + return self.builders[0] + + # Go down the list of features in order, and eliminate any builders + # that don't match every feature. + features = list(features) + features.reverse() + candidates = None + candidate_set = None + while len(features) > 0: + feature = features.pop() + we_have_the_feature = self.builders_for_feature.get(feature, []) + if len(we_have_the_feature) > 0: + if candidates is None: + candidates = we_have_the_feature + candidate_set = set(candidates) + else: + # Eliminate any candidates that don't have this feature. + candidate_set = candidate_set.intersection( + set(we_have_the_feature)) + + # The only valid candidates are the ones in candidate_set. + # Go through the original list of candidates and pick the first one + # that's in candidate_set. + if candidate_set is None: + return None + for candidate in candidates: + if candidate in candidate_set: + return candidate + return None + +# The BeautifulSoup class will take feature lists from developers and use them +# to look up builders in this registry. +builder_registry = TreeBuilderRegistry() + +class TreeBuilder(object): + """Turn a document into a Beautiful Soup object tree.""" + + features = [] + + is_xml = False + preserve_whitespace_tags = set() + empty_element_tags = None # A tag will be considered an empty-element + # tag when and only when it has no contents. + + # A value for these tag/attribute combinations is a space- or + # comma-separated list of CDATA, rather than a single CDATA. + cdata_list_attributes = {} + + + def __init__(self): + self.soup = None + + def reset(self): + pass + + def can_be_empty_element(self, tag_name): + """Might a tag with this name be an empty-element tag? + + The final markup may or may not actually present this tag as + self-closing. + + For instance: an HTMLBuilder does not consider a <p> tag to be + an empty-element tag (it's not in + HTMLBuilder.empty_element_tags). This means an empty <p> tag + will be presented as "<p></p>", not "<p />". + + The default implementation has no opinion about which tags are + empty-element tags, so a tag will be presented as an + empty-element tag if and only if it has no contents. + "<foo></foo>" will become "<foo />", and "<foo>bar</foo>" will + be left alone. + """ + if self.empty_element_tags is None: + return True + return tag_name in self.empty_element_tags + + def feed(self, markup): + raise NotImplementedError() + + def prepare_markup(self, markup, user_specified_encoding=None, + document_declared_encoding=None): + return markup, None, None, False + + def test_fragment_to_document(self, fragment): + """Wrap an HTML fragment to make it look like a document. + + Different parsers do this differently. For instance, lxml + introduces an empty <head> tag, and html5lib + doesn't. Abstracting this away lets us write simple tests + which run HTML fragments through the parser and compare the + results against other HTML fragments. + + This method should not be used outside of tests. + """ + return fragment + + def set_up_substitutions(self, tag): + return False + + def _replace_cdata_list_attribute_values(self, tag_name, attrs): + """Replaces class="foo bar" with class=["foo", "bar"] + + Modifies its input in place. + """ + if self.cdata_list_attributes: + universal = self.cdata_list_attributes.get('*', []) + tag_specific = self.cdata_list_attributes.get( + tag_name.lower(), []) + for cdata_list_attr in itertools.chain(universal, tag_specific): + if cdata_list_attr in dict(attrs): + # Basically, we have a "class" attribute whose + # value is a whitespace-separated list of CSS + # classes. Split it into a list. + value = attrs[cdata_list_attr] + values = whitespace_re.split(value) + attrs[cdata_list_attr] = values + return attrs + +class SAXTreeBuilder(TreeBuilder): + """A Beautiful Soup treebuilder that listens for SAX events.""" + + def feed(self, markup): + raise NotImplementedError() + + def close(self): + pass + + def startElement(self, name, attrs): + attrs = dict((key[1], value) for key, value in list(attrs.items())) + #print "Start %s, %r" % (name, attrs) + self.soup.handle_starttag(name, attrs) + + def endElement(self, name): + #print "End %s" % name + self.soup.handle_endtag(name) + + def startElementNS(self, nsTuple, nodeName, attrs): + # Throw away (ns, nodeName) for now. + self.startElement(nodeName, attrs) + + def endElementNS(self, nsTuple, nodeName): + # Throw away (ns, nodeName) for now. + self.endElement(nodeName) + #handler.endElementNS((ns, node.nodeName), node.nodeName) + + def startPrefixMapping(self, prefix, nodeValue): + # Ignore the prefix for now. + pass + + def endPrefixMapping(self, prefix): + # Ignore the prefix for now. + # handler.endPrefixMapping(prefix) + pass + + def characters(self, content): + self.soup.handle_data(content) + + def startDocument(self): + pass + + def endDocument(self): + pass + + +class HTMLTreeBuilder(TreeBuilder): + """This TreeBuilder knows facts about HTML. + + Such as which tags are empty-element tags. + """ + + preserve_whitespace_tags = set(['pre', 'textarea']) + empty_element_tags = set(['br' , 'hr', 'input', 'img', 'meta', + 'spacer', 'link', 'frame', 'base']) + + # The HTML standard defines these attributes as containing a + # space-separated list of values, not a single value. That is, + # class="foo bar" means that the 'class' attribute has two values, + # 'foo' and 'bar', not the single value 'foo bar'. When we + # encounter one of these attributes, we will parse its value into + # a list of values if possible. Upon output, the list will be + # converted back into a string. + cdata_list_attributes = { + "*" : ['class', 'accesskey', 'dropzone'], + "a" : ['rel', 'rev'], + "link" : ['rel', 'rev'], + "td" : ["headers"], + "th" : ["headers"], + "td" : ["headers"], + "form" : ["accept-charset"], + "object" : ["archive"], + + # These are HTML5 specific, as are *.accesskey and *.dropzone above. + "area" : ["rel"], + "icon" : ["sizes"], + "iframe" : ["sandbox"], + "output" : ["for"], + } + + def set_up_substitutions(self, tag): + # We are only interested in <meta> tags + if tag.name != 'meta': + return False + + http_equiv = tag.get('http-equiv') + content = tag.get('content') + charset = tag.get('charset') + + # We are interested in <meta> tags that say what encoding the + # document was originally in. This means HTML 5-style <meta> + # tags that provide the "charset" attribute. It also means + # HTML 4-style <meta> tags that provide the "content" + # attribute and have "http-equiv" set to "content-type". + # + # In both cases we will replace the value of the appropriate + # attribute with a standin object that can take on any + # encoding. + meta_encoding = None + if charset is not None: + # HTML 5 style: + # <meta charset="utf8"> + meta_encoding = charset + tag['charset'] = CharsetMetaAttributeValue(charset) + + elif (content is not None and http_equiv is not None + and http_equiv.lower() == 'content-type'): + # HTML 4 style: + # <meta http-equiv="content-type" content="text/html; charset=utf8"> + tag['content'] = ContentMetaAttributeValue(content) + + return (meta_encoding is not None) + +def register_treebuilders_from(module): + """Copy TreeBuilders from the given module into this module.""" + # I'm fairly sure this is not the best way to do this. + this_module = sys.modules['bs4.builder'] + for name in module.__all__: + obj = getattr(module, name) + + if issubclass(obj, TreeBuilder): + setattr(this_module, name, obj) + this_module.__all__.append(name) + # Register the builder while we're at it. + this_module.builder_registry.register(obj) + +# Builders are registered in reverse order of priority, so that custom +# builder registrations will take precedence. In general, we want lxml +# to take precedence over html5lib, because it's faster. And we only +# want to use HTMLParser as a last result. +from . import _htmlparser +register_treebuilders_from(_htmlparser) +try: + from . import _html5lib + register_treebuilders_from(_html5lib) +except ImportError: + # They don't have html5lib installed. + pass +try: + from . import _lxml + register_treebuilders_from(_lxml) +except ImportError: + # They don't have lxml installed. + pass diff --git a/bs4/builder/_html5lib.py b/bs4/builder/_html5lib.py new file mode 100644 index 0000000..6001e38 --- /dev/null +++ b/bs4/builder/_html5lib.py @@ -0,0 +1,222 @@ +__all__ = [ + 'HTML5TreeBuilder', + ] + +import warnings +from bs4.builder import ( + PERMISSIVE, + HTML, + HTML_5, + HTMLTreeBuilder, + ) +from bs4.element import NamespacedAttribute +import html5lib +from html5lib.constants import namespaces +from bs4.element import ( + Comment, + Doctype, + NavigableString, + Tag, + ) + +class HTML5TreeBuilder(HTMLTreeBuilder): + """Use html5lib to build a tree.""" + + features = ['html5lib', PERMISSIVE, HTML_5, HTML] + + def prepare_markup(self, markup, user_specified_encoding): + # Store the user-specified encoding for use later on. + self.user_specified_encoding = user_specified_encoding + return markup, None, None, False + + # These methods are defined by Beautiful Soup. + def feed(self, markup): + if self.soup.parse_only is not None: + warnings.warn("You provided a value for parse_only, but the html5lib tree builder doesn't support parse_only. The entire document will be parsed.") + parser = html5lib.HTMLParser(tree=self.create_treebuilder) + doc = parser.parse(markup, encoding=self.user_specified_encoding) + + # Set the character encoding detected by the tokenizer. + if isinstance(markup, unicode): + # We need to special-case this because html5lib sets + # charEncoding to UTF-8 if it gets Unicode input. + doc.original_encoding = None + else: + doc.original_encoding = parser.tokenizer.stream.charEncoding[0] + + def create_treebuilder(self, namespaceHTMLElements): + self.underlying_builder = TreeBuilderForHtml5lib( + self.soup, namespaceHTMLElements) + return self.underlying_builder + + def test_fragment_to_document(self, fragment): + """See `TreeBuilder`.""" + return u'<html><head></head><body>%s</body></html>' % fragment + + +class TreeBuilderForHtml5lib(html5lib.treebuilders._base.TreeBuilder): + + def __init__(self, soup, namespaceHTMLElements): + self.soup = soup + super(TreeBuilderForHtml5lib, self).__init__(namespaceHTMLElements) + + def documentClass(self): + self.soup.reset() + return Element(self.soup, self.soup, None) + + def insertDoctype(self, token): + name = token["name"] + publicId = token["publicId"] + systemId = token["systemId"] + + doctype = Doctype.for_name_and_ids(name, publicId, systemId) + self.soup.object_was_parsed(doctype) + + def elementClass(self, name, namespace): + tag = self.soup.new_tag(name, namespace) + return Element(tag, self.soup, namespace) + + def commentClass(self, data): + return TextNode(Comment(data), self.soup) + + def fragmentClass(self): + self.soup = BeautifulSoup("") + self.soup.name = "[document_fragment]" + return Element(self.soup, self.soup, None) + + def appendChild(self, node): + # XXX This code is not covered by the BS4 tests. + self.soup.append(node.element) + + def getDocument(self): + return self.soup + + def getFragment(self): + return html5lib.treebuilders._base.TreeBuilder.getFragment(self).element + +class AttrList(object): + def __init__(self, element): + self.element = element + self.attrs = dict(self.element.attrs) + def __iter__(self): + return list(self.attrs.items()).__iter__() + def __setitem__(self, name, value): + "set attr", name, value + self.element[name] = value + def items(self): + return list(self.attrs.items()) + def keys(self): + return list(self.attrs.keys()) + def __len__(self): + return len(self.attrs) + def __getitem__(self, name): + return self.attrs[name] + def __contains__(self, name): + return name in list(self.attrs.keys()) + + +class Element(html5lib.treebuilders._base.Node): + def __init__(self, element, soup, namespace): + html5lib.treebuilders._base.Node.__init__(self, element.name) + self.element = element + self.soup = soup + self.namespace = namespace + + def appendChild(self, node): + if (node.element.__class__ == NavigableString and self.element.contents + and self.element.contents[-1].__class__ == NavigableString): + # Concatenate new text onto old text node + # XXX This has O(n^2) performance, for input like + # "a</a>a</a>a</a>..." + old_element = self.element.contents[-1] + new_element = self.soup.new_string(old_element + node.element) + old_element.replace_with(new_element) + else: + self.element.append(node.element) + node.parent = self + + def getAttributes(self): + return AttrList(self.element) + + def setAttributes(self, attributes): + if attributes is not None and len(attributes) > 0: + + converted_attributes = [] + for name, value in list(attributes.items()): + if isinstance(name, tuple): + new_name = NamespacedAttribute(*name) + del attributes[name] + attributes[new_name] = value + + self.soup.builder._replace_cdata_list_attribute_values( + self.name, attributes) + for name, value in attributes.items(): + self.element[name] = value + + # The attributes may contain variables that need substitution. + # Call set_up_substitutions manually. + # + # The Tag constructor called this method when the Tag was created, + # but we just set/changed the attributes, so call it again. + self.soup.builder.set_up_substitutions(self.element) + attributes = property(getAttributes, setAttributes) + + def insertText(self, data, insertBefore=None): + text = TextNode(self.soup.new_string(data), self.soup) + if insertBefore: + self.insertBefore(text, insertBefore) + else: + self.appendChild(text) + + def insertBefore(self, node, refNode): + index = self.element.index(refNode.element) + if (node.element.__class__ == NavigableString and self.element.contents + and self.element.contents[index-1].__class__ == NavigableString): + # (See comments in appendChild) + old_node = self.element.contents[index-1] + new_str = self.soup.new_string(old_node + node.element) + old_node.replace_with(new_str) + else: + self.element.insert(index, node.element) + node.parent = self + + def removeChild(self, node): + node.element.extract() + + def reparentChildren(self, newParent): + while self.element.contents: + child = self.element.contents[0] + child.extract() + if isinstance(child, Tag): + newParent.appendChild( + Element(child, self.soup, namespaces["html"])) + else: + newParent.appendChild( + TextNode(child, self.soup)) + + def cloneNode(self): + tag = self.soup.new_tag(self.element.name, self.namespace) + node = Element(tag, self.soup, self.namespace) + for key,value in self.attributes: + node.attributes[key] = value + return node + + def hasContent(self): + return self.element.contents + + def getNameTuple(self): + if self.namespace == None: + return namespaces["html"], self.name + else: + return self.namespace, self.name + + nameTuple = property(getNameTuple) + +class TextNode(Element): + def __init__(self, element, soup): + html5lib.treebuilders._base.Node.__init__(self, None) + self.element = element + self.soup = soup + + def cloneNode(self): + raise NotImplementedError diff --git a/bs4/builder/_htmlparser.py b/bs4/builder/_htmlparser.py new file mode 100644 index 0000000..ede5cec --- /dev/null +++ b/bs4/builder/_htmlparser.py @@ -0,0 +1,244 @@ +"""Use the HTMLParser library to parse HTML files that aren't too bad.""" + +__all__ = [ + 'HTMLParserTreeBuilder', + ] + +from HTMLParser import ( + HTMLParser, + HTMLParseError, + ) +import sys +import warnings + +# Starting in Python 3.2, the HTMLParser constructor takes a 'strict' +# argument, which we'd like to set to False. Unfortunately, +# http://bugs.python.org/issue13273 makes strict=True a better bet +# before Python 3.2.3. +# +# At the end of this file, we monkeypatch HTMLParser so that +# strict=True works well on Python 3.2.2. +major, minor, release = sys.version_info[:3] +CONSTRUCTOR_TAKES_STRICT = ( + major > 3 + or (major == 3 and minor > 2) + or (major == 3 and minor == 2 and release >= 3)) + +from bs4.element import ( + CData, + Comment, + Declaration, + Doctype, + ProcessingInstruction, + ) +from bs4.dammit import EntitySubstitution, UnicodeDammit + +from bs4.builder import ( + HTML, + HTMLTreeBuilder, + STRICT, + ) + + +HTMLPARSER = 'html.parser' + +class BeautifulSoupHTMLParser(HTMLParser): + def handle_starttag(self, name, attrs): + # XXX namespace + self.soup.handle_starttag(name, None, None, dict(attrs)) + + def handle_endtag(self, name): + self.soup.handle_endtag(name) + + def handle_data(self, data): + self.soup.handle_data(data) + + def handle_charref(self, name): + # XXX workaround for a bug in HTMLParser. Remove this once + # it's fixed. + if name.startswith('x'): + real_name = int(name.lstrip('x'), 16) + else: + real_name = int(name) + + try: + data = unichr(real_name) + except (ValueError, OverflowError), e: + data = u"\N{REPLACEMENT CHARACTER}" + + self.handle_data(data) + + def handle_entityref(self, name): + character = EntitySubstitution.HTML_ENTITY_TO_CHARACTER.get(name) + if character is not None: + data = character + else: + data = "&%s;" % name + self.handle_data(data) + + def handle_comment(self, data): + self.soup.endData() + self.soup.handle_data(data) + self.soup.endData(Comment) + + def handle_decl(self, data): + self.soup.endData() + if data.startswith("DOCTYPE "): + data = data[len("DOCTYPE "):] + self.soup.handle_data(data) + self.soup.endData(Doctype) + + def unknown_decl(self, data): + if data.upper().startswith('CDATA['): + cls = CData + data = data[len('CDATA['):] + else: + cls = Declaration + self.soup.endData() + self.soup.handle_data(data) + self.soup.endData(cls) + + def handle_pi(self, data): + self.soup.endData() + if data.endswith("?") and data.lower().startswith("xml"): + # "An XHTML processing instruction using the trailing '?' + # will cause the '?' to be included in data." - HTMLParser + # docs. + # + # Strip the question mark so we don't end up with two + # question marks. + data = data[:-1] + self.soup.handle_data(data) + self.soup.endData(ProcessingInstruction) + + +class HTMLParserTreeBuilder(HTMLTreeBuilder): + + is_xml = False + features = [HTML, STRICT, HTMLPARSER] + + def __init__(self, *args, **kwargs): + if CONSTRUCTOR_TAKES_STRICT: + kwargs['strict'] = False + self.parser_args = (args, kwargs) + + def prepare_markup(self, markup, user_specified_encoding=None, + document_declared_encoding=None): + """ + :return: A 4-tuple (markup, original encoding, encoding + declared within markup, whether any characters had to be + replaced with REPLACEMENT CHARACTER). + """ + if isinstance(markup, unicode): + return markup, None, None, False + + try_encodings = [user_specified_encoding, document_declared_encoding] + dammit = UnicodeDammit(markup, try_encodings, is_html=True) + return (dammit.markup, dammit.original_encoding, + dammit.declared_html_encoding, + dammit.contains_replacement_characters) + + def feed(self, markup): + args, kwargs = self.parser_args + parser = BeautifulSoupHTMLParser(*args, **kwargs) + parser.soup = self.soup + try: + parser.feed(markup) + except HTMLParseError, e: + warnings.warn(RuntimeWarning( + "Python's built-in HTMLParser cannot parse the given document. This is not a bug in Beautiful Soup. The best solution is to install an external parser (lxml or html5lib), and use Beautiful Soup with that parser. See http://www.crummy.com/software/BeautifulSoup/bs4/doc/#installing-a-parser for help.")) + raise e + +# Patch 3.2 versions of HTMLParser earlier than 3.2.3 to use some +# 3.2.3 code. This ensures they don't treat markup like <p></p> as a +# string. +# +# XXX This code can be removed once most Python 3 users are on 3.2.3. +if major == 3 and minor == 2 and not CONSTRUCTOR_TAKES_STRICT: + import re + attrfind_tolerant = re.compile( + r'\s*((?<=[\'"\s])[^\s/>][^\s/=>]*)(\s*=+\s*' + r'(\'[^\']*\'|"[^"]*"|(?![\'"])[^>\s]*))?') + HTMLParserTreeBuilder.attrfind_tolerant = attrfind_tolerant + + locatestarttagend = re.compile(r""" + <[a-zA-Z][-.a-zA-Z0-9:_]* # tag name + (?:\s+ # whitespace before attribute name + (?:[a-zA-Z_][-.:a-zA-Z0-9_]* # attribute name + (?:\s*=\s* # value indicator + (?:'[^']*' # LITA-enclosed value + |\"[^\"]*\" # LIT-enclosed value + |[^'\">\s]+ # bare value + ) + )? + ) + )* + \s* # trailing whitespace +""", re.VERBOSE) + BeautifulSoupHTMLParser.locatestarttagend = locatestarttagend + + from html.parser import tagfind, attrfind + + def parse_starttag(self, i): + self.__starttag_text = None + endpos = self.check_for_whole_start_tag(i) + if endpos < 0: + return endpos + rawdata = self.rawdata + self.__starttag_text = rawdata[i:endpos] + + # Now parse the data between i+1 and j into a tag and attrs + attrs = [] + match = tagfind.match(rawdata, i+1) + assert match, 'unexpected call to parse_starttag()' + k = match.end() + self.lasttag = tag = rawdata[i+1:k].lower() + while k < endpos: + if self.strict: + m = attrfind.match(rawdata, k) + else: + m = attrfind_tolerant.match(rawdata, k) + if not m: + break + attrname, rest, attrvalue = m.group(1, 2, 3) + if not rest: + attrvalue = None + elif attrvalue[:1] == '\'' == attrvalue[-1:] or \ + attrvalue[:1] == '"' == attrvalue[-1:]: + attrvalue = attrvalue[1:-1] + if attrvalue: + attrvalue = self.unescape(attrvalue) + attrs.append((attrname.lower(), attrvalue)) + k = m.end() + + end = rawdata[k:endpos].strip() + if end not in (">", "/>"): + lineno, offset = self.getpos() + if "\n" in self.__starttag_text: + lineno = lineno + self.__starttag_text.count("\n") + offset = len(self.__starttag_text) \ + - self.__starttag_text.rfind("\n") + else: + offset = offset + len(self.__starttag_text) + if self.strict: + self.error("junk characters in start tag: %r" + % (rawdata[k:endpos][:20],)) + self.handle_data(rawdata[i:endpos]) + return endpos + if end.endswith('/>'): + # XHTML-style empty tag: <span attr="value" /> + self.handle_startendtag(tag, attrs) + else: + self.handle_starttag(tag, attrs) + if tag in self.CDATA_CONTENT_ELEMENTS: + self.set_cdata_mode(tag) + return endpos + + def set_cdata_mode(self, elem): + self.cdata_elem = elem.lower() + self.interesting = re.compile(r'</\s*%s\s*>' % self.cdata_elem, re.I) + + BeautifulSoupHTMLParser.parse_starttag = parse_starttag + BeautifulSoupHTMLParser.set_cdata_mode = set_cdata_mode + + CONSTRUCTOR_TAKES_STRICT = True diff --git a/bs4/builder/_lxml.py b/bs4/builder/_lxml.py new file mode 100644 index 0000000..c78fdff --- /dev/null +++ b/bs4/builder/_lxml.py @@ -0,0 +1,179 @@ +__all__ = [ + 'LXMLTreeBuilderForXML', + 'LXMLTreeBuilder', + ] + +from StringIO import StringIO +import collections +from lxml import etree +from bs4.element import Comment, Doctype, NamespacedAttribute +from bs4.builder import ( + FAST, + HTML, + HTMLTreeBuilder, + PERMISSIVE, + TreeBuilder, + XML) +from bs4.dammit import UnicodeDammit + +LXML = 'lxml' + +class LXMLTreeBuilderForXML(TreeBuilder): + DEFAULT_PARSER_CLASS = etree.XMLParser + + is_xml = True + + # Well, it's permissive by XML parser standards. + features = [LXML, XML, FAST, PERMISSIVE] + + CHUNK_SIZE = 512 + + @property + def default_parser(self): + # This can either return a parser object or a class, which + # will be instantiated with default arguments. + return etree.XMLParser(target=self, strip_cdata=False, recover=True) + + def __init__(self, parser=None, empty_element_tags=None): + if empty_element_tags is not None: + self.empty_element_tags = set(empty_element_tags) + if parser is None: + # Use the default parser. + parser = self.default_parser + if isinstance(parser, collections.Callable): + # Instantiate the parser with default arguments + parser = parser(target=self, strip_cdata=False) + self.parser = parser + self.soup = None + self.nsmaps = None + + def _getNsTag(self, tag): + # Split the namespace URL out of a fully-qualified lxml tag + # name. Copied from lxml's src/lxml/sax.py. + if tag[0] == '{': + return tuple(tag[1:].split('}', 1)) + else: + return (None, tag) + + def prepare_markup(self, markup, user_specified_encoding=None, + document_declared_encoding=None): + """ + :return: A 3-tuple (markup, original encoding, encoding + declared within markup). + """ + if isinstance(markup, unicode): + return markup, None, None, False + + try_encodings = [user_specified_encoding, document_declared_encoding] + dammit = UnicodeDammit(markup, try_encodings, is_html=True) + return (dammit.markup, dammit.original_encoding, + dammit.declared_html_encoding, + dammit.contains_replacement_characters) + + def feed(self, markup): + if isinstance(markup, basestring): + markup = StringIO(markup) + # Call feed() at least once, even if the markup is empty, + # or the parser won't be initialized. + data = markup.read(self.CHUNK_SIZE) + self.parser.feed(data) + while data != '': + # Now call feed() on the rest of the data, chunk by chunk. + data = markup.read(self.CHUNK_SIZE) + if data != '': + self.parser.feed(data) + self.parser.close() + + def close(self): + self.nsmaps = None + + def start(self, name, attrs, nsmap={}): + # Make sure attrs is a mutable dict--lxml may send an immutable dictproxy. + attrs = dict(attrs) + + nsprefix = None + # Invert each namespace map as it comes in. + if len(nsmap) == 0 and self.nsmaps != None: + # There are no new namespaces for this tag, but namespaces + # are in play, so we need a separate tag stack to know + # when they end. + self.nsmaps.append(None) + elif len(nsmap) > 0: + # A new namespace mapping has come into play. + if self.nsmaps is None: + self.nsmaps = [] + inverted_nsmap = dict((value, key) for key, value in nsmap.items()) + self.nsmaps.append(inverted_nsmap) + # Also treat the namespace mapping as a set of attributes on the + # tag, so we can recreate it later. + attrs = attrs.copy() + for prefix, namespace in nsmap.items(): + attribute = NamespacedAttribute( + "xmlns", prefix, "http://www.w3.org/2000/xmlns/") + attrs[attribute] = namespace + namespace, name = self._getNsTag(name) + if namespace is not None: + for inverted_nsmap in reversed(self.nsmaps): + if inverted_nsmap is not None and namespace in inverted_nsmap: + nsprefix = inverted_nsmap[namespace] + break + self.soup.handle_starttag(name, namespace, nsprefix, attrs) + + def end(self, name): + self.soup.endData() + completed_tag = self.soup.tagStack[-1] + namespace, name = self._getNsTag(name) + nsprefix = None + if namespace is not None: + for inverted_nsmap in reversed(self.nsmaps): + if inverted_nsmap is not None and namespace in inverted_nsmap: + nsprefix = inverted_nsmap[namespace] + break + self.soup.handle_endtag(name, nsprefix) + if self.nsmaps != None: + # This tag, or one of its parents, introduced a namespace + # mapping, so pop it off the stack. + self.nsmaps.pop() + if len(self.nsmaps) == 0: + # Namespaces are no longer in play, so don't bother keeping + # track of the namespace stack. + self.nsmaps = None + + def pi(self, target, data): + pass + + def data(self, content): + self.soup.handle_data(content) + + def doctype(self, name, pubid, system): + self.soup.endData() + doctype = Doctype.for_name_and_ids(name, pubid, system) + self.soup.object_was_parsed(doctype) + + def comment(self, content): + "Handle comments as Comment objects." + self.soup.endData() + self.soup.handle_data(content) + self.soup.endData(Comment) + + def test_fragment_to_document(self, fragment): + """See `TreeBuilder`.""" + return u'<?xml version="1.0" encoding="utf-8"?>\n%s' % fragment + + +class LXMLTreeBuilder(HTMLTreeBuilder, LXMLTreeBuilderForXML): + + features = [LXML, HTML, FAST, PERMISSIVE] + is_xml = False + + @property + def default_parser(self): + return etree.HTMLParser + + def feed(self, markup): + self.parser.feed(markup) + self.parser.close() + + def test_fragment_to_document(self, fragment): + """See `TreeBuilder`.""" + return u'<html><body>%s</body></html>' % fragment diff --git a/bs4/dammit.py b/bs4/dammit.py new file mode 100644 index 0000000..58cad9b --- /dev/null +++ b/bs4/dammit.py @@ -0,0 +1,792 @@ +# -*- coding: utf-8 -*- +"""Beautiful Soup bonus library: Unicode, Dammit + +This class forces XML data into a standard format (usually to UTF-8 or +Unicode). It is heavily based on code from Mark Pilgrim's Universal +Feed Parser. It does not rewrite the XML or HTML to reflect a new +encoding; that's the tree builder's job. +""" + +import codecs +from htmlentitydefs import codepoint2name +import re +import warnings + +# Autodetects character encodings. Very useful. +# Download from http://chardet.feedparser.org/ +# or 'apt-get install python-chardet' +# or 'easy_install chardet' +try: + import chardet + #import chardet.constants + #chardet.constants._debug = 1 +except ImportError: + chardet = None + +# Available from http://cjkpython.i18n.org/. +try: + import iconv_codec +except ImportError: + pass + +xml_encoding_re = re.compile( + '^<\?.*encoding=[\'"](.*?)[\'"].*\?>'.encode(), re.I) +html_meta_re = re.compile( + '<\s*meta[^>]+charset\s*=\s*["\']?([^>]*?)[ /;\'">]'.encode(), re.I) + +class EntitySubstitution(object): + + """Substitute XML or HTML entities for the corresponding characters.""" + + def _populate_class_variables(): + lookup = {} + reverse_lookup = {} + characters_for_re = [] + for codepoint, name in list(codepoint2name.items()): + character = unichr(codepoint) + if codepoint != 34: + # There's no point in turning the quotation mark into + # ", unless it happens within an attribute value, which + # is handled elsewhere. + characters_for_re.append(character) + lookup[character] = name + # But we do want to turn " into the quotation mark. + reverse_lookup[name] = character + re_definition = "[%s]" % "".join(characters_for_re) + return lookup, reverse_lookup, re.compile(re_definition) + (CHARACTER_TO_HTML_ENTITY, HTML_ENTITY_TO_CHARACTER, + CHARACTER_TO_HTML_ENTITY_RE) = _populate_class_variables() + + CHARACTER_TO_XML_ENTITY = { + "'": "apos", + '"': "quot", + "&": "amp", + "<": "lt", + ">": "gt", + } + + BARE_AMPERSAND_OR_BRACKET = re.compile("([<>]|" + "&(?!#\d+;|#x[0-9a-fA-F]+;|\w+;)" + ")") + + @classmethod + def _substitute_html_entity(cls, matchobj): + entity = cls.CHARACTER_TO_HTML_ENTITY.get(matchobj.group(0)) + return "&%s;" % entity + + @classmethod + def _substitute_xml_entity(cls, matchobj): + """Used with a regular expression to substitute the + appropriate XML entity for an XML special character.""" + entity = cls.CHARACTER_TO_XML_ENTITY[matchobj.group(0)] + return "&%s;" % entity + + @classmethod + def quoted_attribute_value(self, value): + """Make a value into a quoted XML attribute, possibly escaping it. + + Most strings will be quoted using double quotes. + + Bob's Bar -> "Bob's Bar" + + If a string contains double quotes, it will be quoted using + single quotes. + + Welcome to "my bar" -> 'Welcome to "my bar"' + + If a string contains both single and double quotes, the + double quotes will be escaped, and the string will be quoted + using double quotes. + + Welcome to "Bob's Bar" -> "Welcome to "Bob's bar" + """ + quote_with = '"' + if '"' in value: + if "'" in value: + # The string contains both single and double + # quotes. Turn the double quotes into + # entities. We quote the double quotes rather than + # the single quotes because the entity name is + # """ whether this is HTML or XML. If we + # quoted the single quotes, we'd have to decide + # between ' and &squot;. + replace_with = """ + value = value.replace('"', replace_with) + else: + # There are double quotes but no single quotes. + # We can use single quotes to quote the attribute. + quote_with = "'" + return quote_with + value + quote_with + + @classmethod + def substitute_xml(cls, value, make_quoted_attribute=False): + """Substitute XML entities for special XML characters. + + :param value: A string to be substituted. The less-than sign will + become <, the greater-than sign will become >, and any + ampersands that are not part of an entity defition will + become &. + + :param make_quoted_attribute: If True, then the string will be + quoted, as befits an attribute value. + """ + # Escape angle brackets, and ampersands that aren't part of + # entities. + value = cls.BARE_AMPERSAND_OR_BRACKET.sub( + cls._substitute_xml_entity, value) + + if make_quoted_attribute: + value = cls.quoted_attribute_value(value) + return value + + @classmethod + def substitute_html(cls, s): + """Replace certain Unicode characters with named HTML entities. + + This differs from data.encode(encoding, 'xmlcharrefreplace') + in that the goal is to make the result more readable (to those + with ASCII displays) rather than to recover from + errors. There's absolutely nothing wrong with a UTF-8 string + containg a LATIN SMALL LETTER E WITH ACUTE, but replacing that + character with "é" will make it more readable to some + people. + """ + return cls.CHARACTER_TO_HTML_ENTITY_RE.sub( + cls._substitute_html_entity, s) + + +class UnicodeDammit: + """A class for detecting the encoding of a *ML document and + converting it to a Unicode string. If the source encoding is + windows-1252, can replace MS smart quotes with their HTML or XML + equivalents.""" + + # This dictionary maps commonly seen values for "charset" in HTML + # meta tags to the corresponding Python codec names. It only covers + # values that aren't in Python's aliases and can't be determined + # by the heuristics in find_codec. + CHARSET_ALIASES = {"macintosh": "mac-roman", + "x-sjis": "shift-jis"} + + ENCODINGS_WITH_SMART_QUOTES = [ + "windows-1252", + "iso-8859-1", + "iso-8859-2", + ] + + def __init__(self, markup, override_encodings=[], + smart_quotes_to=None, is_html=False): + self.declared_html_encoding = None + self.smart_quotes_to = smart_quotes_to + self.tried_encodings = [] + self.contains_replacement_characters = False + + if markup == '' or isinstance(markup, unicode): + self.markup = markup + self.unicode_markup = unicode(markup) + self.original_encoding = None + return + + new_markup, document_encoding, sniffed_encoding = \ + self._detectEncoding(markup, is_html) + self.markup = new_markup + + u = None + if new_markup != markup: + # _detectEncoding modified the markup, then converted it to + # Unicode and then to UTF-8. So convert it from UTF-8. + u = self._convert_from("utf8") + self.original_encoding = sniffed_encoding + + if not u: + for proposed_encoding in ( + override_encodings + [document_encoding, sniffed_encoding]): + if proposed_encoding is not None: + u = self._convert_from(proposed_encoding) + if u: + break + + # If no luck and we have auto-detection library, try that: + if not u and chardet and not isinstance(self.markup, unicode): + u = self._convert_from(chardet.detect(self.markup)['encoding']) + + # As a last resort, try utf-8 and windows-1252: + if not u: + for proposed_encoding in ("utf-8", "windows-1252"): + u = self._convert_from(proposed_encoding) + if u: + break + + # As an absolute last resort, try the encodings again with + # character replacement. + if not u: + for proposed_encoding in ( + override_encodings + [ + document_encoding, sniffed_encoding, "utf-8", "windows-1252"]): + if proposed_encoding != "ascii": + u = self._convert_from(proposed_encoding, "replace") + if u is not None: + warnings.warn( + UnicodeWarning( + "Some characters could not be decoded, and were " + "replaced with REPLACEMENT CHARACTER.")) + self.contains_replacement_characters = True + break + + # We could at this point force it to ASCII, but that would + # destroy so much data that I think giving up is better + self.unicode_markup = u + if not u: + self.original_encoding = None + + def _sub_ms_char(self, match): + """Changes a MS smart quote character to an XML or HTML + entity, or an ASCII character.""" + orig = match.group(1) + if self.smart_quotes_to == 'ascii': + sub = self.MS_CHARS_TO_ASCII.get(orig).encode() + else: + sub = self.MS_CHARS.get(orig) + if type(sub) == tuple: + if self.smart_quotes_to == 'xml': + sub = '&#x'.encode() + sub[1].encode() + ';'.encode() + else: + sub = '&'.encode() + sub[0].encode() + ';'.encode() + else: + sub = sub.encode() + return sub + + def _convert_from(self, proposed, errors="strict"): + proposed = self.find_codec(proposed) + if not proposed or (proposed, errors) in self.tried_encodings: + return None + self.tried_encodings.append((proposed, errors)) + markup = self.markup + + # Convert smart quotes to HTML if coming from an encoding + # that might have them. + if (self.smart_quotes_to is not None + and proposed.lower() in self.ENCODINGS_WITH_SMART_QUOTES): + smart_quotes_re = b"([\x80-\x9f])" + smart_quotes_compiled = re.compile(smart_quotes_re) + markup = smart_quotes_compiled.sub(self._sub_ms_char, markup) + + try: + #print "Trying to convert document to %s (errors=%s)" % ( + # proposed, errors) + u = self._to_unicode(markup, proposed, errors) + self.markup = u + self.original_encoding = proposed + except Exception as e: + #print "That didn't work!" + #print e + return None + #print "Correct encoding: %s" % proposed + return self.markup + + def _to_unicode(self, data, encoding, errors="strict"): + '''Given a string and its encoding, decodes the string into Unicode. + %encoding is a string recognized by encodings.aliases''' + + # strip Byte Order Mark (if present) + if (len(data) >= 4) and (data[:2] == '\xfe\xff') \ + and (data[2:4] != '\x00\x00'): + encoding = 'utf-16be' + data = data[2:] + elif (len(data) >= 4) and (data[:2] == '\xff\xfe') \ + and (data[2:4] != '\x00\x00'): + encoding = 'utf-16le' + data = data[2:] + elif data[:3] == '\xef\xbb\xbf': + encoding = 'utf-8' + data = data[3:] + elif data[:4] == '\x00\x00\xfe\xff': + encoding = 'utf-32be' + data = data[4:] + elif data[:4] == '\xff\xfe\x00\x00': + encoding = 'utf-32le' + data = data[4:] + newdata = unicode(data, encoding, errors) + return newdata + + def _detectEncoding(self, xml_data, is_html=False): + """Given a document, tries to detect its XML encoding.""" + xml_encoding = sniffed_xml_encoding = None + try: + if xml_data[:4] == b'\x4c\x6f\xa7\x94': + # EBCDIC + xml_data = self._ebcdic_to_ascii(xml_data) + elif xml_data[:4] == b'\x00\x3c\x00\x3f': + # UTF-16BE + sniffed_xml_encoding = 'utf-16be' + xml_data = unicode(xml_data, 'utf-16be').encode('utf-8') + elif (len(xml_data) >= 4) and (xml_data[:2] == b'\xfe\xff') \ + and (xml_data[2:4] != b'\x00\x00'): + # UTF-16BE with BOM + sniffed_xml_encoding = 'utf-16be' + xml_data = unicode(xml_data[2:], 'utf-16be').encode('utf-8') + elif xml_data[:4] == b'\x3c\x00\x3f\x00': + # UTF-16LE + sniffed_xml_encoding = 'utf-16le' + xml_data = unicode(xml_data, 'utf-16le').encode('utf-8') + elif (len(xml_data) >= 4) and (xml_data[:2] == b'\xff\xfe') and \ + (xml_data[2:4] != b'\x00\x00'): + # UTF-16LE with BOM + sniffed_xml_encoding = 'utf-16le' + xml_data = unicode(xml_data[2:], 'utf-16le').encode('utf-8') + elif xml_data[:4] == b'\x00\x00\x00\x3c': + # UTF-32BE + sniffed_xml_encoding = 'utf-32be' + xml_data = unicode(xml_data, 'utf-32be').encode('utf-8') + elif xml_data[:4] == b'\x3c\x00\x00\x00': + # UTF-32LE + sniffed_xml_encoding = 'utf-32le' + xml_data = unicode(xml_data, 'utf-32le').encode('utf-8') + elif xml_data[:4] == b'\x00\x00\xfe\xff': + # UTF-32BE with BOM + sniffed_xml_encoding = 'utf-32be' + xml_data = unicode(xml_data[4:], 'utf-32be').encode('utf-8') + elif xml_data[:4] == b'\xff\xfe\x00\x00': + # UTF-32LE with BOM + sniffed_xml_encoding = 'utf-32le' + xml_data = unicode(xml_data[4:], 'utf-32le').encode('utf-8') + elif xml_data[:3] == b'\xef\xbb\xbf': + # UTF-8 with BOM + sniffed_xml_encoding = 'utf-8' + xml_data = unicode(xml_data[3:], 'utf-8').encode('utf-8') + else: + sniffed_xml_encoding = 'ascii' + pass + except: + xml_encoding_match = None + xml_encoding_match = xml_encoding_re.match(xml_data) + if not xml_encoding_match and is_html: + xml_encoding_match = html_meta_re.search(xml_data) + if xml_encoding_match is not None: + xml_encoding = xml_encoding_match.groups()[0].decode( + 'ascii').lower() + if is_html: + self.declared_html_encoding = xml_encoding + if sniffed_xml_encoding and \ + (xml_encoding in ('iso-10646-ucs-2', 'ucs-2', 'csunicode', + 'iso-10646-ucs-4', 'ucs-4', 'csucs4', + 'utf-16', 'utf-32', 'utf_16', 'utf_32', + 'utf16', 'u16')): + xml_encoding = sniffed_xml_encoding + return xml_data, xml_encoding, sniffed_xml_encoding + + def find_codec(self, charset): + return self._codec(self.CHARSET_ALIASES.get(charset, charset)) \ + or (charset and self._codec(charset.replace("-", ""))) \ + or (charset and self._codec(charset.replace("-", "_"))) \ + or charset + + def _codec(self, charset): + if not charset: + return charset + codec = None + try: + codecs.lookup(charset) + codec = charset + except (LookupError, ValueError): + pass + return codec + + EBCDIC_TO_ASCII_MAP = None + + def _ebcdic_to_ascii(self, s): + c = self.__class__ + if not c.EBCDIC_TO_ASCII_MAP: + emap = (0,1,2,3,156,9,134,127,151,141,142,11,12,13,14,15, + 16,17,18,19,157,133,8,135,24,25,146,143,28,29,30,31, + 128,129,130,131,132,10,23,27,136,137,138,139,140,5,6,7, + 144,145,22,147,148,149,150,4,152,153,154,155,20,21,158,26, + 32,160,161,162,163,164,165,166,167,168,91,46,60,40,43,33, + 38,169,170,171,172,173,174,175,176,177,93,36,42,41,59,94, + 45,47,178,179,180,181,182,183,184,185,124,44,37,95,62,63, + 186,187,188,189,190,191,192,193,194,96,58,35,64,39,61,34, + 195,97,98,99,100,101,102,103,104,105,196,197,198,199,200, + 201,202,106,107,108,109,110,111,112,113,114,203,204,205, + 206,207,208,209,126,115,116,117,118,119,120,121,122,210, + 211,212,213,214,215,216,217,218,219,220,221,222,223,224, + 225,226,227,228,229,230,231,123,65,66,67,68,69,70,71,72, + 73,232,233,234,235,236,237,125,74,75,76,77,78,79,80,81, + 82,238,239,240,241,242,243,92,159,83,84,85,86,87,88,89, + 90,244,245,246,247,248,249,48,49,50,51,52,53,54,55,56,57, + 250,251,252,253,254,255) + import string + c.EBCDIC_TO_ASCII_MAP = string.maketrans( + ''.join(map(chr, list(range(256)))), ''.join(map(chr, emap))) + return s.translate(c.EBCDIC_TO_ASCII_MAP) + + # A partial mapping of ISO-Latin-1 to HTML entities/XML numeric entities. + MS_CHARS = {b'\x80': ('euro', '20AC'), + b'\x81': ' ', + b'\x82': ('sbquo', '201A'), + b'\x83': ('fnof', '192'), + b'\x84': ('bdquo', '201E'), + b'\x85': ('hellip', '2026'), + b'\x86': ('dagger', '2020'), + b'\x87': ('Dagger', '2021'), + b'\x88': ('circ', '2C6'), + b'\x89': ('permil', '2030'), + b'\x8A': ('Scaron', '160'), + b'\x8B': ('lsaquo', '2039'), + b'\x8C': ('OElig', '152'), + b'\x8D': '?', + b'\x8E': ('#x17D', '17D'), + b'\x8F': '?', + b'\x90': '?', + b'\x91': ('lsquo', '2018'), + b'\x92': ('rsquo', '2019'), + b'\x93': ('ldquo', '201C'), + b'\x94': ('rdquo', '201D'), + b'\x95': ('bull', '2022'), + b'\x96': ('ndash', '2013'), + b'\x97': ('mdash', '2014'), + b'\x98': ('tilde', '2DC'), + b'\x99': ('trade', '2122'), + b'\x9a': ('scaron', '161'), + b'\x9b': ('rsaquo', '203A'), + b'\x9c': ('oelig', '153'), + b'\x9d': '?', + b'\x9e': ('#x17E', '17E'), + b'\x9f': ('Yuml', ''),} + + # A parochial partial mapping of ISO-Latin-1 to ASCII. Contains + # horrors like stripping diacritical marks to turn á into a, but also + # contains non-horrors like turning “ into ". + MS_CHARS_TO_ASCII = { + b'\x80' : 'EUR', + b'\x81' : ' ', + b'\x82' : ',', + b'\x83' : 'f', + b'\x84' : ',,', + b'\x85' : '...', + b'\x86' : '+', + b'\x87' : '++', + b'\x88' : '^', + b'\x89' : '%', + b'\x8a' : 'S', + b'\x8b' : '<', + b'\x8c' : 'OE', + b'\x8d' : '?', + b'\x8e' : 'Z', + b'\x8f' : '?', + b'\x90' : '?', + b'\x91' : "'", + b'\x92' : "'", + b'\x93' : '"', + b'\x94' : '"', + b'\x95' : '*', + b'\x96' : '-', + b'\x97' : '--', + b'\x98' : '~', + b'\x99' : '(TM)', + b'\x9a' : 's', + b'\x9b' : '>', + b'\x9c' : 'oe', + b'\x9d' : '?', + b'\x9e' : 'z', + b'\x9f' : 'Y', + b'\xa0' : ' ', + b'\xa1' : '!', + b'\xa2' : 'c', + b'\xa3' : 'GBP', + b'\xa4' : '$', #This approximation is especially parochial--this is the + #generic currency symbol. + b'\xa5' : 'YEN', + b'\xa6' : '|', + b'\xa7' : 'S', + b'\xa8' : '..', + b'\xa9' : '', + b'\xaa' : '(th)', + b'\xab' : '<<', + b'\xac' : '!', + b'\xad' : ' ', + b'\xae' : '(R)', + b'\xaf' : '-', + b'\xb0' : 'o', + b'\xb1' : '+-', + b'\xb2' : '2', + b'\xb3' : '3', + b'\xb4' : ("'", 'acute'), + b'\xb5' : 'u', + b'\xb6' : 'P', + b'\xb7' : '*', + b'\xb8' : ',', + b'\xb9' : '1', + b'\xba' : '(th)', + b'\xbb' : '>>', + b'\xbc' : '1/4', + b'\xbd' : '1/2', + b'\xbe' : '3/4', + b'\xbf' : '?', + b'\xc0' : 'A', + b'\xc1' : 'A', + b'\xc2' : 'A', + b'\xc3' : 'A', + b'\xc4' : 'A', + b'\xc5' : 'A', + b'\xc6' : 'AE', + b'\xc7' : 'C', + b'\xc8' : 'E', + b'\xc9' : 'E', + b'\xca' : 'E', + b'\xcb' : 'E', + b'\xcc' : 'I', + b'\xcd' : 'I', + b'\xce' : 'I', + b'\xcf' : 'I', + b'\xd0' : 'D', + b'\xd1' : 'N', + b'\xd2' : 'O', + b'\xd3' : 'O', + b'\xd4' : 'O', + b'\xd5' : 'O', + b'\xd6' : 'O', + b'\xd7' : '*', + b'\xd8' : 'O', + b'\xd9' : 'U', + b'\xda' : 'U', + b'\xdb' : 'U', + b'\xdc' : 'U', + b'\xdd' : 'Y', + b'\xde' : 'b', + b'\xdf' : 'B', + b'\xe0' : 'a', + b'\xe1' : 'a', + b'\xe2' : 'a', + b'\xe3' : 'a', + b'\xe4' : 'a', + b'\xe5' : 'a', + b'\xe6' : 'ae', + b'\xe7' : 'c', + b'\xe8' : 'e', + b'\xe9' : 'e', + b'\xea' : 'e', + b'\xeb' : 'e', + b'\xec' : 'i', + b'\xed' : 'i', + b'\xee' : 'i', + b'\xef' : 'i', + b'\xf0' : 'o', + b'\xf1' : 'n', + b'\xf2' : 'o', + b'\xf3' : 'o', + b'\xf4' : 'o', + b'\xf5' : 'o', + b'\xf6' : 'o', + b'\xf7' : '/', + b'\xf8' : 'o', + b'\xf9' : 'u', + b'\xfa' : 'u', + b'\xfb' : 'u', + b'\xfc' : 'u', + b'\xfd' : 'y', + b'\xfe' : 'b', + b'\xff' : 'y', + } + + # A map used when removing rogue Windows-1252/ISO-8859-1 + # characters in otherwise UTF-8 documents. + # + # Note that \x81, \x8d, \x8f, \x90, and \x9d are undefined in + # Windows-1252. + WINDOWS_1252_TO_UTF8 = { + 0x80 : b'\xe2\x82\xac', # € + 0x82 : b'\xe2\x80\x9a', # ‚ + 0x83 : b'\xc6\x92', # ƒ + 0x84 : b'\xe2\x80\x9e', # „ + 0x85 : b'\xe2\x80\xa6', # … + 0x86 : b'\xe2\x80\xa0', # † + 0x87 : b'\xe2\x80\xa1', # ‡ + 0x88 : b'\xcb\x86', # ˆ + 0x89 : b'\xe2\x80\xb0', # ‰ + 0x8a : b'\xc5\xa0', # Š + 0x8b : b'\xe2\x80\xb9', # ‹ + 0x8c : b'\xc5\x92', # Œ + 0x8e : b'\xc5\xbd', # Ž + 0x91 : b'\xe2\x80\x98', # ‘ + 0x92 : b'\xe2\x80\x99', # ’ + 0x93 : b'\xe2\x80\x9c', # “ + 0x94 : b'\xe2\x80\x9d', # ” + 0x95 : b'\xe2\x80\xa2', # • + 0x96 : b'\xe2\x80\x93', # – + 0x97 : b'\xe2\x80\x94', # — + 0x98 : b'\xcb\x9c', # ˜ + 0x99 : b'\xe2\x84\xa2', # ™ + 0x9a : b'\xc5\xa1', # š + 0x9b : b'\xe2\x80\xba', # › + 0x9c : b'\xc5\x93', # œ + 0x9e : b'\xc5\xbe', # ž + 0x9f : b'\xc5\xb8', # Ÿ + 0xa0 : b'\xc2\xa0', #   + 0xa1 : b'\xc2\xa1', # ¡ + 0xa2 : b'\xc2\xa2', # ¢ + 0xa3 : b'\xc2\xa3', # £ + 0xa4 : b'\xc2\xa4', # ¤ + 0xa5 : b'\xc2\xa5', # ¥ + 0xa6 : b'\xc2\xa6', # ¦ + 0xa7 : b'\xc2\xa7', # § + 0xa8 : b'\xc2\xa8', # ¨ + 0xa9 : b'\xc2\xa9', # © + 0xaa : b'\xc2\xaa', # ª + 0xab : b'\xc2\xab', # « + 0xac : b'\xc2\xac', # ¬ + 0xad : b'\xc2\xad', # ­ + 0xae : b'\xc2\xae', # ® + 0xaf : b'\xc2\xaf', # ¯ + 0xb0 : b'\xc2\xb0', # ° + 0xb1 : b'\xc2\xb1', # ± + 0xb2 : b'\xc2\xb2', # ² + 0xb3 : b'\xc2\xb3', # ³ + 0xb4 : b'\xc2\xb4', # ´ + 0xb5 : b'\xc2\xb5', # µ + 0xb6 : b'\xc2\xb6', # ¶ + 0xb7 : b'\xc2\xb7', # · + 0xb8 : b'\xc2\xb8', # ¸ + 0xb9 : b'\xc2\xb9', # ¹ + 0xba : b'\xc2\xba', # º + 0xbb : b'\xc2\xbb', # » + 0xbc : b'\xc2\xbc', # ¼ + 0xbd : b'\xc2\xbd', # ½ + 0xbe : b'\xc2\xbe', # ¾ + 0xbf : b'\xc2\xbf', # ¿ + 0xc0 : b'\xc3\x80', # À + 0xc1 : b'\xc3\x81', # Á + 0xc2 : b'\xc3\x82', #  + 0xc3 : b'\xc3\x83', # à + 0xc4 : b'\xc3\x84', # Ä + 0xc5 : b'\xc3\x85', # Å + 0xc6 : b'\xc3\x86', # Æ + 0xc7 : b'\xc3\x87', # Ç + 0xc8 : b'\xc3\x88', # È + 0xc9 : b'\xc3\x89', # É + 0xca : b'\xc3\x8a', # Ê + 0xcb : b'\xc3\x8b', # Ë + 0xcc : b'\xc3\x8c', # Ì + 0xcd : b'\xc3\x8d', # Í + 0xce : b'\xc3\x8e', # Î + 0xcf : b'\xc3\x8f', # Ï + 0xd0 : b'\xc3\x90', # Ð + 0xd1 : b'\xc3\x91', # Ñ + 0xd2 : b'\xc3\x92', # Ò + 0xd3 : b'\xc3\x93', # Ó + 0xd4 : b'\xc3\x94', # Ô + 0xd5 : b'\xc3\x95', # Õ + 0xd6 : b'\xc3\x96', # Ö + 0xd7 : b'\xc3\x97', # × + 0xd8 : b'\xc3\x98', # Ø + 0xd9 : b'\xc3\x99', # Ù + 0xda : b'\xc3\x9a', # Ú + 0xdb : b'\xc3\x9b', # Û + 0xdc : b'\xc3\x9c', # Ü + 0xdd : b'\xc3\x9d', # Ý + 0xde : b'\xc3\x9e', # Þ + 0xdf : b'\xc3\x9f', # ß + 0xe0 : b'\xc3\xa0', # à + 0xe1 : b'\xa1', # á + 0xe2 : b'\xc3\xa2', # â + 0xe3 : b'\xc3\xa3', # ã + 0xe4 : b'\xc3\xa4', # ä + 0xe5 : b'\xc3\xa5', # å + 0xe6 : b'\xc3\xa6', # æ + 0xe7 : b'\xc3\xa7', # ç + 0xe8 : b'\xc3\xa8', # è + 0xe9 : b'\xc3\xa9', # é + 0xea : b'\xc3\xaa', # ê + 0xeb : b'\xc3\xab', # ë + 0xec : b'\xc3\xac', # ì + 0xed : b'\xc3\xad', # í + 0xee : b'\xc3\xae', # î + 0xef : b'\xc3\xaf', # ï + 0xf0 : b'\xc3\xb0', # ð + 0xf1 : b'\xc3\xb1', # ñ + 0xf2 : b'\xc3\xb2', # ò + 0xf3 : b'\xc3\xb3', # ó + 0xf4 : b'\xc3\xb4', # ô + 0xf5 : b'\xc3\xb5', # õ + 0xf6 : b'\xc3\xb6', # ö + 0xf7 : b'\xc3\xb7', # ÷ + 0xf8 : b'\xc3\xb8', # ø + 0xf9 : b'\xc3\xb9', # ù + 0xfa : b'\xc3\xba', # ú + 0xfb : b'\xc3\xbb', # û + 0xfc : b'\xc3\xbc', # ü + 0xfd : b'\xc3\xbd', # ý + 0xfe : b'\xc3\xbe', # þ + } + + MULTIBYTE_MARKERS_AND_SIZES = [ + (0xc2, 0xdf, 2), # 2-byte characters start with a byte C2-DF + (0xe0, 0xef, 3), # 3-byte characters start with E0-EF + (0xf0, 0xf4, 4), # 4-byte characters start with F0-F4 + ] + + FIRST_MULTIBYTE_MARKER = MULTIBYTE_MARKERS_AND_SIZES[0][0] + LAST_MULTIBYTE_MARKER = MULTIBYTE_MARKERS_AND_SIZES[-1][1] + + @classmethod + def detwingle(cls, in_bytes, main_encoding="utf8", + embedded_encoding="windows-1252"): + """Fix characters from one encoding embedded in some other encoding. + + Currently the only situation supported is Windows-1252 (or its + subset ISO-8859-1), embedded in UTF-8. + + The input must be a bytestring. If you've already converted + the document to Unicode, you're too late. + + The output is a bytestring in which `embedded_encoding` + characters have been converted to their `main_encoding` + equivalents. + """ + if embedded_encoding.replace('_', '-').lower() not in ( + 'windows-1252', 'windows_1252'): + raise NotImplementedError( + "Windows-1252 and ISO-8859-1 are the only currently supported " + "embedded encodings.") + + if main_encoding.lower() not in ('utf8', 'utf-8'): + raise NotImplementedError( + "UTF-8 is the only currently supported main encoding.") + + byte_chunks = [] + + chunk_start = 0 + pos = 0 + while pos < len(in_bytes): + byte = in_bytes[pos] + if not isinstance(byte, int): + # Python 2.x + byte = ord(byte) + if (byte >= cls.FIRST_MULTIBYTE_MARKER + and byte <= cls.LAST_MULTIBYTE_MARKER): + # This is the start of a UTF-8 multibyte character. Skip + # to the end. + for start, end, size in cls.MULTIBYTE_MARKERS_AND_SIZES: + if byte >= start and byte <= end: + pos += size + break + elif byte >= 0x80 and byte in cls.WINDOWS_1252_TO_UTF8: + # We found a Windows-1252 character! + # Save the string up to this point as a chunk. + byte_chunks.append(in_bytes[chunk_start:pos]) + + # Now translate the Windows-1252 character into UTF-8 + # and add it as another, one-byte chunk. + byte_chunks.append(cls.WINDOWS_1252_TO_UTF8[byte]) + pos += 1 + chunk_start = pos + else: + # Go on to the next character. + pos += 1 + if chunk_start == 0: + # The string is unchanged. + return in_bytes + else: + # Store the final chunk. + byte_chunks.append(in_bytes[chunk_start:]) + return b''.join(byte_chunks) + diff --git a/bs4/element.py b/bs4/element.py new file mode 100644 index 0000000..91a4007 --- /dev/null +++ b/bs4/element.py @@ -0,0 +1,1347 @@ +import collections +import re +import sys +import warnings +from bs4.dammit import EntitySubstitution + +DEFAULT_OUTPUT_ENCODING = "utf-8" +PY3K = (sys.version_info[0] > 2) + +whitespace_re = re.compile("\s+") + +def _alias(attr): + """Alias one attribute name to another for backward compatibility""" + @property + def alias(self): + return getattr(self, attr) + + @alias.setter + def alias(self): + return setattr(self, attr) + return alias + + +class NamespacedAttribute(unicode): + + def __new__(cls, prefix, name, namespace=None): + if name is None: + obj = unicode.__new__(cls, prefix) + else: + obj = unicode.__new__(cls, prefix + ":" + name) + obj.prefix = prefix + obj.name = name + obj.namespace = namespace + return obj + +class AttributeValueWithCharsetSubstitution(unicode): + """A stand-in object for a character encoding specified in HTML.""" + +class CharsetMetaAttributeValue(AttributeValueWithCharsetSubstitution): + """A generic stand-in for the value of a meta tag's 'charset' attribute. + + When Beautiful Soup parses the markup '<meta charset="utf8">', the + value of the 'charset' attribute will be one of these objects. + """ + + def __new__(cls, original_value): + obj = unicode.__new__(cls, original_value) + obj.original_value = original_value + return obj + + def encode(self, encoding): + return encoding + + +class ContentMetaAttributeValue(AttributeValueWithCharsetSubstitution): + """A generic stand-in for the value of a meta tag's 'content' attribute. + + When Beautiful Soup parses the markup: + <meta http-equiv="content-type" content="text/html; charset=utf8"> + + The value of the 'content' attribute will be one of these objects. + """ + + CHARSET_RE = re.compile("((^|;)\s*charset=)([^;]*)", re.M) + + def __new__(cls, original_value): + match = cls.CHARSET_RE.search(original_value) + if match is None: + # No substitution necessary. + return unicode.__new__(unicode, original_value) + + obj = unicode.__new__(cls, original_value) + obj.original_value = original_value + return obj + + def encode(self, encoding): + def rewrite(match): + return match.group(1) + encoding + return self.CHARSET_RE.sub(rewrite, self.original_value) + + +class PageElement(object): + """Contains the navigational information for some part of the page + (either a tag or a piece of text)""" + + # There are five possible values for the "formatter" argument passed in + # to methods like encode() and prettify(): + # + # "html" - All Unicode characters with corresponding HTML entities + # are converted to those entities on output. + # "minimal" - Bare ampersands and angle brackets are converted to + # XML entities: & < > + # None - The null formatter. Unicode characters are never + # converted to entities. This is not recommended, but it's + # faster than "minimal". + # A function - This function will be called on every string that + # needs to undergo entity substition + FORMATTERS = { + "html" : EntitySubstitution.substitute_html, + "minimal" : EntitySubstitution.substitute_xml, + None : None + } + + @classmethod + def format_string(self, s, formatter='minimal'): + """Format the given string using the given formatter.""" + if not callable(formatter): + formatter = self.FORMATTERS.get( + formatter, EntitySubstitution.substitute_xml) + if formatter is None: + output = s + else: + output = formatter(s) + return output + + def setup(self, parent=None, previous_element=None): + """Sets up the initial relations between this element and + other elements.""" + self.parent = parent + self.previous_element = previous_element + if previous_element is not None: + self.previous_element.next_element = self + self.next_element = None + self.previous_sibling = None + self.next_sibling = None + if self.parent is not None and self.parent.contents: + self.previous_sibling = self.parent.contents[-1] + self.previous_sibling.next_sibling = self + + nextSibling = _alias("next_sibling") # BS3 + previousSibling = _alias("previous_sibling") # BS3 + + def replace_with(self, replace_with): + if replace_with is self: + return + if replace_with is self.parent: + raise ValueError("Cannot replace a Tag with its parent.") + old_parent = self.parent + my_index = self.parent.index(self) + self.extract() + old_parent.insert(my_index, replace_with) + return self + replaceWith = replace_with # BS3 + + def unwrap(self): + my_parent = self.parent + my_index = self.parent.index(self) + self.extract() + for child in reversed(self.contents[:]): + my_parent.insert(my_index, child) + return self + replace_with_children = unwrap + replaceWithChildren = unwrap # BS3 + + def wrap(self, wrap_inside): + me = self.replace_with(wrap_inside) + wrap_inside.append(me) + return wrap_inside + + def extract(self): + """Destructively rips this element out of the tree.""" + if self.parent is not None: + del self.parent.contents[self.parent.index(self)] + + #Find the two elements that would be next to each other if + #this element (and any children) hadn't been parsed. Connect + #the two. + last_child = self._last_descendant() + next_element = last_child.next_element + + if self.previous_element is not None: + self.previous_element.next_element = next_element + if next_element is not None: + next_element.previous_element = self.previous_element + self.previous_element = None + last_child.next_element = None + + self.parent = None + if self.previous_sibling is not None: + self.previous_sibling.next_sibling = self.next_sibling + if self.next_sibling is not None: + self.next_sibling.previous_sibling = self.previous_sibling + self.previous_sibling = self.next_sibling = None + return self + + def _last_descendant(self): + "Finds the last element beneath this object to be parsed." + last_child = self + while hasattr(last_child, 'contents') and last_child.contents: + last_child = last_child.contents[-1] + return last_child + # BS3: Not part of the API! + _lastRecursiveChild = _last_descendant + + def insert(self, position, new_child): + if new_child is self: + raise ValueError("Cannot insert a tag into itself.") + if (isinstance(new_child, basestring) + and not isinstance(new_child, NavigableString)): + new_child = NavigableString(new_child) + + position = min(position, len(self.contents)) + if hasattr(new_child, 'parent') and new_child.parent is not None: + # We're 'inserting' an element that's already one + # of this object's children. + if new_child.parent is self: + current_index = self.index(new_child) + if current_index < position: + # We're moving this element further down the list + # of this object's children. That means that when + # we extract this element, our target index will + # jump down one. + position -= 1 + new_child.extract() + + new_child.parent = self + previous_child = None + if position == 0: + new_child.previous_sibling = None + new_child.previous_element = self + else: + previous_child = self.contents[position - 1] + new_child.previous_sibling = previous_child + new_child.previous_sibling.next_sibling = new_child + new_child.previous_element = previous_child._last_descendant() + if new_child.previous_element is not None: + new_child.previous_element.next_element = new_child + + new_childs_last_element = new_child._last_descendant() + + if position >= len(self.contents): + new_child.next_sibling = None + + parent = self + parents_next_sibling = None + while parents_next_sibling is None and parent is not None: + parents_next_sibling = parent.next_sibling + parent = parent.parent + if parents_next_sibling is not None: + # We found the element that comes next in the document. + break + if parents_next_sibling is not None: + new_childs_last_element.next_element = parents_next_sibling + else: + # The last element of this tag is the last element in + # the document. + new_childs_last_element.next_element = None + else: + next_child = self.contents[position] + new_child.next_sibling = next_child + if new_child.next_sibling is not None: + new_child.next_sibling.previous_sibling = new_child + new_childs_last_element.next_element = next_child + + if new_childs_last_element.next_element is not None: + new_childs_last_element.next_element.previous_element = new_childs_last_element + self.contents.insert(position, new_child) + + def append(self, tag): + """Appends the given tag to the contents of this tag.""" + self.insert(len(self.contents), tag) + + def insert_before(self, predecessor): + """Makes the given element the immediate predecessor of this one. + + The two elements will have the same parent, and the given element + will be immediately before this one. + """ + if self is predecessor: + raise ValueError("Can't insert an element before itself.") + parent = self.parent + if parent is None: + raise ValueError( + "Element has no parent, so 'before' has no meaning.") + # Extract first so that the index won't be screwed up if they + # are siblings. + if isinstance(predecessor, PageElement): + predecessor.extract() + index = parent.index(self) + parent.insert(index, predecessor) + + def insert_after(self, successor): + """Makes the given element the immediate successor of this one. + + The two elements will have the same parent, and the given element + will be immediately after this one. + """ + if self is successor: + raise ValueError("Can't insert an element after itself.") + parent = self.parent + if parent is None: + raise ValueError( + "Element has no parent, so 'after' has no meaning.") + # Extract first so that the index won't be screwed up if they + # are siblings. + if isinstance(successor, PageElement): + successor.extract() + index = parent.index(self) + parent.insert(index+1, successor) + + def find_next(self, name=None, attrs={}, text=None, **kwargs): + """Returns the first item that matches the given criteria and + appears after this Tag in the document.""" + return self._find_one(self.find_all_next, name, attrs, text, **kwargs) + findNext = find_next # BS3 + + def find_all_next(self, name=None, attrs={}, text=None, limit=None, + **kwargs): + """Returns all items that match the given criteria and appear + after this Tag in the document.""" + return self._find_all(name, attrs, text, limit, self.next_elements, + **kwargs) + findAllNext = find_all_next # BS3 + + def find_next_sibling(self, name=None, attrs={}, text=None, **kwargs): + """Returns the closest sibling to this Tag that matches the + given criteria and appears after this Tag in the document.""" + return self._find_one(self.find_next_siblings, name, attrs, text, + **kwargs) + findNextSibling = find_next_sibling # BS3 + + def find_next_siblings(self, name=None, attrs={}, text=None, limit=None, + **kwargs): + """Returns the siblings of this Tag that match the given + criteria and appear after this Tag in the document.""" + return self._find_all(name, attrs, text, limit, + self.next_siblings, **kwargs) + findNextSiblings = find_next_siblings # BS3 + fetchNextSiblings = find_next_siblings # BS2 + + def find_previous(self, name=None, attrs={}, text=None, **kwargs): + """Returns the first item that matches the given criteria and + appears before this Tag in the document.""" + return self._find_one( + self.find_all_previous, name, attrs, text, **kwargs) + findPrevious = find_previous # BS3 + + def find_all_previous(self, name=None, attrs={}, text=None, limit=None, + **kwargs): + """Returns all items that match the given criteria and appear + before this Tag in the document.""" + return self._find_all(name, attrs, text, limit, self.previous_elements, + **kwargs) + findAllPrevious = find_all_previous # BS3 + fetchPrevious = find_all_previous # BS2 + + def find_previous_sibling(self, name=None, attrs={}, text=None, **kwargs): + """Returns the closest sibling to this Tag that matches the + given criteria and appears before this Tag in the document.""" + return self._find_one(self.find_previous_siblings, name, attrs, text, + **kwargs) + findPreviousSibling = find_previous_sibling # BS3 + + def find_previous_siblings(self, name=None, attrs={}, text=None, + limit=None, **kwargs): + """Returns the siblings of this Tag that match the given + criteria and appear before this Tag in the document.""" + return self._find_all(name, attrs, text, limit, + self.previous_siblings, **kwargs) + findPreviousSiblings = find_previous_siblings # BS3 + fetchPreviousSiblings = find_previous_siblings # BS2 + + def find_parent(self, name=None, attrs={}, **kwargs): + """Returns the closest parent of this Tag that matches the given + criteria.""" + # NOTE: We can't use _find_one because findParents takes a different + # set of arguments. + r = None + l = self.find_parents(name, attrs, 1) + if l: + r = l[0] + return r + findParent = find_parent # BS3 + + def find_parents(self, name=None, attrs={}, limit=None, **kwargs): + """Returns the parents of this Tag that match the given + criteria.""" + + return self._find_all(name, attrs, None, limit, self.parents, + **kwargs) + findParents = find_parents # BS3 + fetchParents = find_parents # BS2 + + @property + def next(self): + return self.next_element + + @property + def previous(self): + return self.previous_element + + #These methods do the real heavy lifting. + + def _find_one(self, method, name, attrs, text, **kwargs): + r = None + l = method(name, attrs, text, 1, **kwargs) + if l: + r = l[0] + return r + + def _find_all(self, name, attrs, text, limit, generator, **kwargs): + "Iterates over a generator looking for things that match." + + if isinstance(name, SoupStrainer): + strainer = name + elif text is None and not limit and not attrs and not kwargs: + # Optimization to find all tags. + if name is True or name is None: + return [element for element in generator + if isinstance(element, Tag)] + # Optimization to find all tags with a given name. + elif isinstance(name, basestring): + return [element for element in generator + if isinstance(element, Tag) and element.name == name] + else: + strainer = SoupStrainer(name, attrs, text, **kwargs) + else: + # Build a SoupStrainer + strainer = SoupStrainer(name, attrs, text, **kwargs) + results = ResultSet(strainer) + while True: + try: + i = next(generator) + except StopIteration: + break + if i: + found = strainer.search(i) + if found: + results.append(found) + if limit and len(results) >= limit: + break + return results + + #These generators can be used to navigate starting from both + #NavigableStrings and Tags. + @property + def next_elements(self): + i = self.next_element + while i is not None: + yield i + i = i.next_element + + @property + def next_siblings(self): + i = self.next_sibling + while i is not None: + yield i + i = i.next_sibling + + @property + def previous_elements(self): + i = self.previous_element + while i is not None: + yield i + i = i.previous_element + + @property + def previous_siblings(self): + i = self.previous_sibling + while i is not None: + yield i + i = i.previous_sibling + + @property + def parents(self): + i = self.parent + while i is not None: + yield i + i = i.parent + + # Methods for supporting CSS selectors. + + tag_name_re = re.compile('^[a-z0-9]+$') + + # /^(\w+)\[(\w+)([=~\|\^\$\*]?)=?"?([^\]"]*)"?\]$/ + # \---/ \---/\-------------/ \-------/ + # | | | | + # | | | The value + # | | ~,|,^,$,* or = + # | Attribute + # Tag + attribselect_re = re.compile( + r'^(?P<tag>\w+)?\[(?P<attribute>\w+)(?P<operator>[=~\|\^\$\*]?)' + + r'=?"?(?P<value>[^\]"]*)"?\]$' + ) + + def _attr_value_as_string(self, value, default=None): + """Force an attribute value into a string representation. + + A multi-valued attribute will be converted into a + space-separated stirng. + """ + value = self.get(value, default) + if isinstance(value, list) or isinstance(value, tuple): + value =" ".join(value) + return value + + def _attribute_checker(self, operator, attribute, value=''): + """Create a function that performs a CSS selector operation. + + Takes an operator, attribute and optional value. Returns a + function that will return True for elements that match that + combination. + """ + if operator == '=': + # string representation of `attribute` is equal to `value` + return lambda el: el._attr_value_as_string(attribute) == value + elif operator == '~': + # space-separated list representation of `attribute` + # contains `value` + def _includes_value(element): + attribute_value = element.get(attribute, []) + if not isinstance(attribute_value, list): + attribute_value = attribute_value.split() + return value in attribute_value + return _includes_value + elif operator == '^': + # string representation of `attribute` starts with `value` + return lambda el: el._attr_value_as_string( + attribute, '').startswith(value) + elif operator == '$': + # string represenation of `attribute` ends with `value` + return lambda el: el._attr_value_as_string( + attribute, '').endswith(value) + elif operator == '*': + # string representation of `attribute` contains `value` + return lambda el: value in el._attr_value_as_string(attribute, '') + elif operator == '|': + # string representation of `attribute` is either exactly + # `value` or starts with `value` and then a dash. + def _is_or_starts_with_dash(element): + attribute_value = element._attr_value_as_string(attribute, '') + return (attribute_value == value or attribute_value.startswith( + value + '-')) + return _is_or_starts_with_dash + else: + return lambda el: el.has_attr(attribute) + + def select(self, selector): + """Perform a CSS selection operation on the current element.""" + tokens = selector.split() + current_context = [self] + for index, token in enumerate(tokens): + if tokens[index - 1] == '>': + # already found direct descendants in last step. skip this + # step. + continue + m = self.attribselect_re.match(token) + if m is not None: + # Attribute selector + tag, attribute, operator, value = m.groups() + if not tag: + tag = True + checker = self._attribute_checker(operator, attribute, value) + found = [] + for context in current_context: + found.extend( + [el for el in context.find_all(tag) if checker(el)]) + current_context = found + continue + + if '#' in token: + # ID selector + tag, id = token.split('#', 1) + if tag == "": + tag = True + el = current_context[0].find(tag, {'id': id}) + if el is None: + return [] # No match + current_context = [el] + continue + + if '.' in token: + # Class selector + tag_name, klass = token.split('.', 1) + if not tag_name: + tag_name = True + classes = set(klass.split('.')) + found = [] + def classes_match(tag): + if tag_name is not True and tag.name != tag_name: + return False + if not tag.has_attr('class'): + return False + return classes.issubset(tag['class']) + for context in current_context: + found.extend(context.find_all(classes_match)) + current_context = found + continue + + if token == '*': + # Star selector + found = [] + for context in current_context: + found.extend(context.findAll(True)) + current_context = found + continue + + if token == '>': + # Child selector + tag = tokens[index + 1] + if not tag: + tag = True + + found = [] + for context in current_context: + found.extend(context.find_all(tag, recursive=False)) + current_context = found + continue + + # Here we should just have a regular tag + if not self.tag_name_re.match(token): + return [] + found = [] + for context in current_context: + found.extend(context.findAll(token)) + current_context = found + return current_context + + # Old non-property versions of the generators, for backwards + # compatibility with BS3. + def nextGenerator(self): + return self.next_elements + + def nextSiblingGenerator(self): + return self.next_siblings + + def previousGenerator(self): + return self.previous_elements + + def previousSiblingGenerator(self): + return self.previous_siblings + + def parentGenerator(self): + return self.parents + + +class NavigableString(unicode, PageElement): + + PREFIX = '' + SUFFIX = '' + + def __new__(cls, value): + """Create a new NavigableString. + + When unpickling a NavigableString, this method is called with + the string in DEFAULT_OUTPUT_ENCODING. That encoding needs to be + passed in to the superclass's __new__ or the superclass won't know + how to handle non-ASCII characters. + """ + if isinstance(value, unicode): + return unicode.__new__(cls, value) + return unicode.__new__(cls, value, DEFAULT_OUTPUT_ENCODING) + + def __getnewargs__(self): + return (unicode(self),) + + def __getattr__(self, attr): + """text.string gives you text. This is for backwards + compatibility for Navigable*String, but for CData* it lets you + get the string without the CData wrapper.""" + if attr == 'string': + return self + else: + raise AttributeError( + "'%s' object has no attribute '%s'" % ( + self.__class__.__name__, attr)) + + def output_ready(self, formatter="minimal"): + output = self.format_string(self, formatter) + return self.PREFIX + output + self.SUFFIX + + +class PreformattedString(NavigableString): + """A NavigableString not subject to the normal formatting rules. + + The string will be passed into the formatter (to trigger side effects), + but the return value will be ignored. + """ + + def output_ready(self, formatter="minimal"): + """CData strings are passed into the formatter. + But the return value is ignored.""" + self.format_string(self, formatter) + return self.PREFIX + self + self.SUFFIX + +class CData(PreformattedString): + + PREFIX = u'<![CDATA[' + SUFFIX = u']]>' + +class ProcessingInstruction(PreformattedString): + + PREFIX = u'<?' + SUFFIX = u'?>' + +class Comment(PreformattedString): + + PREFIX = u'<!--' + SUFFIX = u'-->' + + +class Declaration(PreformattedString): + PREFIX = u'<!' + SUFFIX = u'!>' + + +class Doctype(PreformattedString): + + @classmethod + def for_name_and_ids(cls, name, pub_id, system_id): + value = name + if pub_id is not None: + value += ' PUBLIC "%s"' % pub_id + if system_id is not None: + value += ' "%s"' % system_id + elif system_id is not None: + value += ' SYSTEM "%s"' % system_id + + return Doctype(value) + + PREFIX = u'<!DOCTYPE ' + SUFFIX = u'>\n' + + +class Tag(PageElement): + + """Represents a found HTML tag with its attributes and contents.""" + + def __init__(self, parser=None, builder=None, name=None, namespace=None, + prefix=None, attrs=None, parent=None, previous=None): + "Basic constructor." + + if parser is None: + self.parser_class = None + else: + # We don't actually store the parser object: that lets extracted + # chunks be garbage-collected. + self.parser_class = parser.__class__ + if name is None: + raise ValueError("No value provided for new tag's name.") + self.name = name + self.namespace = namespace + self.prefix = prefix + if attrs is None: + attrs = {} + elif builder.cdata_list_attributes: + attrs = builder._replace_cdata_list_attribute_values( + self.name, attrs) + else: + attrs = dict(attrs) + self.attrs = attrs + self.contents = [] + self.setup(parent, previous) + self.hidden = False + + # Set up any substitutions, such as the charset in a META tag. + if builder is not None: + builder.set_up_substitutions(self) + self.can_be_empty_element = builder.can_be_empty_element(name) + else: + self.can_be_empty_element = False + + parserClass = _alias("parser_class") # BS3 + + @property + def is_empty_element(self): + """Is this tag an empty-element tag? (aka a self-closing tag) + + A tag that has contents is never an empty-element tag. + + A tag that has no contents may or may not be an empty-element + tag. It depends on the builder used to create the tag. If the + builder has a designated list of empty-element tags, then only + a tag whose name shows up in that list is considered an + empty-element tag. + + If the builder has no designated list of empty-element tags, + then any tag with no contents is an empty-element tag. + """ + return len(self.contents) == 0 and self.can_be_empty_element + isSelfClosing = is_empty_element # BS3 + + @property + def string(self): + """Convenience property to get the single string within this tag. + + :Return: If this tag has a single string child, return value + is that string. If this tag has no children, or more than one + child, return value is None. If this tag has one child tag, + return value is the 'string' attribute of the child tag, + recursively. + """ + if len(self.contents) != 1: + return None + child = self.contents[0] + if isinstance(child, NavigableString): + return child + return child.string + + @string.setter + def string(self, string): + self.clear() + self.append(string.__class__(string)) + + def _all_strings(self, strip=False): + """Yield all child strings, possibly stripping them.""" + for descendant in self.descendants: + if not isinstance(descendant, NavigableString): + continue + if strip: + descendant = descendant.strip() + if len(descendant) == 0: + continue + yield descendant + strings = property(_all_strings) + + @property + def stripped_strings(self): + for string in self._all_strings(True): + yield string + + def get_text(self, separator="", strip=False): + """ + Get all child strings, concatenated using the given separator. + """ + return separator.join([s for s in self._all_strings(strip)]) + getText = get_text + text = property(get_text) + + def decompose(self): + """Recursively destroys the contents of this tree.""" + self.extract() + i = self + while i is not None: + next = i.next_element + i.__dict__.clear() + i = next + + def clear(self, decompose=False): + """ + Extract all children. If decompose is True, decompose instead. + """ + if decompose: + for element in self.contents[:]: + if isinstance(element, Tag): + element.decompose() + else: + element.extract() + else: + for element in self.contents[:]: + element.extract() + + def index(self, element): + """ + Find the index of a child by identity, not value. Avoids issues with + tag.contents.index(element) getting the index of equal elements. + """ + for i, child in enumerate(self.contents): + if child is element: + return i + raise ValueError("Tag.index: element not in tag") + + def get(self, key, default=None): + """Returns the value of the 'key' attribute for the tag, or + the value given for 'default' if it doesn't have that + attribute.""" + return self.attrs.get(key, default) + + def has_attr(self, key): + return key in self.attrs + + def __hash__(self): + return str(self).__hash__() + + def __getitem__(self, key): + """tag[key] returns the value of the 'key' attribute for the tag, + and throws an exception if it's not there.""" + return self.attrs[key] + + def __iter__(self): + "Iterating over a tag iterates over its contents." + return iter(self.contents) + + def __len__(self): + "The length of a tag is the length of its list of contents." + return len(self.contents) + + def __contains__(self, x): + return x in self.contents + + def __nonzero__(self): + "A tag is non-None even if it has no contents." + return True + + def __setitem__(self, key, value): + """Setting tag[key] sets the value of the 'key' attribute for the + tag.""" + self.attrs[key] = value + + def __delitem__(self, key): + "Deleting tag[key] deletes all 'key' attributes for the tag." + self.attrs.pop(key, None) + + def __call__(self, *args, **kwargs): + """Calling a tag like a function is the same as calling its + find_all() method. Eg. tag('a') returns a list of all the A tags + found within this tag.""" + return self.find_all(*args, **kwargs) + + def __getattr__(self, tag): + #print "Getattr %s.%s" % (self.__class__, tag) + if len(tag) > 3 and tag.endswith('Tag'): + # BS3: soup.aTag -> "soup.find("a") + tag_name = tag[:-3] + warnings.warn( + '.%sTag is deprecated, use .find("%s") instead.' % ( + tag_name, tag_name)) + return self.find(tag_name) + # We special case contents to avoid recursion. + elif not tag.startswith("__") and not tag=="contents": + return self.find(tag) + raise AttributeError( + "'%s' object has no attribute '%s'" % (self.__class__, tag)) + + def __eq__(self, other): + """Returns true iff this tag has the same name, the same attributes, + and the same contents (recursively) as the given tag.""" + if self is other: + return True + if (not hasattr(other, 'name') or + not hasattr(other, 'attrs') or + not hasattr(other, 'contents') or + self.name != other.name or + self.attrs != other.attrs or + len(self) != len(other)): + return False + for i, my_child in enumerate(self.contents): + if my_child != other.contents[i]: + return False + return True + + def __ne__(self, other): + """Returns true iff this tag is not identical to the other tag, + as defined in __eq__.""" + return not self == other + + def __repr__(self, encoding=DEFAULT_OUTPUT_ENCODING): + """Renders this tag as a string.""" + return self.encode(encoding) + + def __unicode__(self): + return self.decode() + + def __str__(self): + return self.encode() + + if PY3K: + __str__ = __repr__ = __unicode__ + + def encode(self, encoding=DEFAULT_OUTPUT_ENCODING, + indent_level=None, formatter="minimal", + errors="xmlcharrefreplace"): + # Turn the data structure into Unicode, then encode the + # Unicode. + u = self.decode(indent_level, encoding, formatter) + return u.encode(encoding, errors) + + def decode(self, indent_level=None, + eventual_encoding=DEFAULT_OUTPUT_ENCODING, + formatter="minimal"): + """Returns a Unicode representation of this tag and its contents. + + :param eventual_encoding: The tag is destined to be + encoded into this encoding. This method is _not_ + responsible for performing that encoding. This information + is passed in so that it can be substituted in if the + document contains a <META> tag that mentions the document's + encoding. + """ + attrs = [] + if self.attrs: + for key, val in sorted(self.attrs.items()): + if val is None: + decoded = key + else: + if isinstance(val, list) or isinstance(val, tuple): + val = ' '.join(val) + elif not isinstance(val, basestring): + val = str(val) + elif ( + isinstance(val, AttributeValueWithCharsetSubstitution) + and eventual_encoding is not None): + val = val.encode(eventual_encoding) + + text = self.format_string(val, formatter) + decoded = ( + str(key) + '=' + + EntitySubstitution.quoted_attribute_value(text)) + attrs.append(decoded) + close = '' + closeTag = '' + if self.is_empty_element: + close = '/' + else: + closeTag = '</%s>' % self.name + + prefix = '' + if self.prefix: + prefix = self.prefix + ":" + + pretty_print = (indent_level is not None) + if pretty_print: + space = (' ' * (indent_level - 1)) + indent_contents = indent_level + 1 + else: + space = '' + indent_contents = None + contents = self.decode_contents( + indent_contents, eventual_encoding, formatter) + + if self.hidden: + # This is the 'document root' object. + s = contents + else: + s = [] + attribute_string = '' + if attrs: + attribute_string = ' ' + ' '.join(attrs) + if pretty_print: + s.append(space) + s.append('<%s%s%s%s>' % ( + prefix, self.name, attribute_string, close)) + if pretty_print: + s.append("\n") + s.append(contents) + if pretty_print and contents and contents[-1] != "\n": + s.append("\n") + if pretty_print and closeTag: + s.append(space) + s.append(closeTag) + if pretty_print and closeTag and self.next_sibling: + s.append("\n") + s = ''.join(s) + return s + + def prettify(self, encoding=None, formatter="minimal"): + if encoding is None: + return self.decode(True, formatter=formatter) + else: + return self.encode(encoding, True, formatter=formatter) + + def decode_contents(self, indent_level=None, + eventual_encoding=DEFAULT_OUTPUT_ENCODING, + formatter="minimal"): + """Renders the contents of this tag as a Unicode string. + + :param eventual_encoding: The tag is destined to be + encoded into this encoding. This method is _not_ + responsible for performing that encoding. This information + is passed in so that it can be substituted in if the + document contains a <META> tag that mentions the document's + encoding. + """ + pretty_print = (indent_level is not None) + s = [] + for c in self: + text = None + if isinstance(c, NavigableString): + text = c.output_ready(formatter) + elif isinstance(c, Tag): + s.append(c.decode(indent_level, eventual_encoding, + formatter)) + if text and indent_level: + text = text.strip() + if text: + if pretty_print: + s.append(" " * (indent_level - 1)) + s.append(text) + if pretty_print: + s.append("\n") + return ''.join(s) + + def encode_contents( + self, indent_level=None, encoding=DEFAULT_OUTPUT_ENCODING, + formatter="minimal"): + """Renders the contents of this tag as a bytestring.""" + contents = self.decode_contents(indent_level, encoding, formatter) + return contents.encode(encoding) + + # Old method for BS3 compatibility + def renderContents(self, encoding=DEFAULT_OUTPUT_ENCODING, + prettyPrint=False, indentLevel=0): + if not prettyPrint: + indentLevel = None + return self.encode_contents( + indent_level=indentLevel, encoding=encoding) + + #Soup methods + + def find(self, name=None, attrs={}, recursive=True, text=None, + **kwargs): + """Return only the first child of this Tag matching the given + criteria.""" + r = None + l = self.find_all(name, attrs, recursive, text, 1, **kwargs) + if l: + r = l[0] + return r + findChild = find + + def find_all(self, name=None, attrs={}, recursive=True, text=None, + limit=None, **kwargs): + """Extracts a list of Tag objects that match the given + criteria. You can specify the name of the Tag and any + attributes you want the Tag to have. + + The value of a key-value pair in the 'attrs' map can be a + string, a list of strings, a regular expression object, or a + callable that takes a string and returns whether or not the + string matches for some custom definition of 'matches'. The + same is true of the tag name.""" + generator = self.descendants + if not recursive: + generator = self.children + return self._find_all(name, attrs, text, limit, generator, **kwargs) + findAll = find_all # BS3 + findChildren = find_all # BS2 + + #Generator methods + @property + def children(self): + # return iter() to make the purpose of the method clear + return iter(self.contents) # XXX This seems to be untested. + + @property + def descendants(self): + if not len(self.contents): + return + stopNode = self._last_descendant().next_element + current = self.contents[0] + while current is not stopNode: + yield current + current = current.next_element + + # Old names for backwards compatibility + def childGenerator(self): + return self.children + + def recursiveChildGenerator(self): + return self.descendants + + # This was kind of misleading because has_key() (attributes) was + # different from __in__ (contents). has_key() is gone in Python 3, + # anyway. + has_key = has_attr + +# Next, a couple classes to represent queries and their results. +class SoupStrainer(object): + """Encapsulates a number of ways of matching a markup element (tag or + text).""" + + def __init__(self, name=None, attrs={}, text=None, **kwargs): + self.name = self._normalize_search_value(name) + if not isinstance(attrs, dict): + # Treat a non-dict value for attrs as a search for the 'class' + # attribute. + kwargs['class'] = attrs + attrs = None + + if kwargs: + if attrs: + attrs = attrs.copy() + attrs.update(kwargs) + else: + attrs = kwargs + normalized_attrs = {} + for key, value in attrs.items(): + normalized_attrs[key] = self._normalize_search_value(value) + + self.attrs = normalized_attrs + self.text = self._normalize_search_value(text) + + def _normalize_search_value(self, value): + # Leave it alone if it's a Unicode string, a callable, a + # regular expression, a boolean, or None. + if (isinstance(value, unicode) or callable(value) or hasattr(value, 'match') + or isinstance(value, bool) or value is None): + return value + + # If it's a bytestring, convert it to Unicode, treating it as UTF-8. + if isinstance(value, bytes): + return value.decode("utf8") + + # If it's listlike, convert it into a list of strings. + if hasattr(value, '__iter__'): + new_value = [] + for v in value: + if (hasattr(v, '__iter__') and not isinstance(v, bytes) + and not isinstance(v, unicode)): + # This is almost certainly the user's mistake. In the + # interests of avoiding infinite loops, we'll let + # it through as-is rather than doing a recursive call. + new_value.append(v) + else: + new_value.append(self._normalize_search_value(v)) + return new_value + + # Otherwise, convert it into a Unicode string. + # The unicode(str()) thing is so this will do the same thing on Python 2 + # and Python 3. + return unicode(str(value)) + + def __str__(self): + if self.text: + return self.text + else: + return "%s|%s" % (self.name, self.attrs) + + def search_tag(self, markup_name=None, markup_attrs={}): + found = None + markup = None + if isinstance(markup_name, Tag): + markup = markup_name + markup_attrs = markup + call_function_with_tag_data = ( + isinstance(self.name, collections.Callable) + and not isinstance(markup_name, Tag)) + + if ((not self.name) + or call_function_with_tag_data + or (markup and self._matches(markup, self.name)) + or (not markup and self._matches(markup_name, self.name))): + if call_function_with_tag_data: + match = self.name(markup_name, markup_attrs) + else: + match = True + markup_attr_map = None + for attr, match_against in list(self.attrs.items()): + if not markup_attr_map: + if hasattr(markup_attrs, 'get'): + markup_attr_map = markup_attrs + else: + markup_attr_map = {} + for k, v in markup_attrs: + markup_attr_map[k] = v + attr_value = markup_attr_map.get(attr) + if not self._matches(attr_value, match_against): + match = False + break + if match: + if markup: + found = markup + else: + found = markup_name + if found and self.text and not self._matches(found.string, self.text): + found = None + return found + searchTag = search_tag + + def search(self, markup): + # print 'looking for %s in %s' % (self, markup) + found = None + # If given a list of items, scan it for a text element that + # matches. + if hasattr(markup, '__iter__') and not isinstance(markup, (Tag, basestring)): + for element in markup: + if isinstance(element, NavigableString) \ + and self.search(element): + found = element + break + # If it's a Tag, make sure its name or attributes match. + # Don't bother with Tags if we're searching for text. + elif isinstance(markup, Tag): + if not self.text or self.name or self.attrs: + found = self.search_tag(markup) + # If it's text, make sure the text matches. + elif isinstance(markup, NavigableString) or \ + isinstance(markup, basestring): + if not self.name and not self.attrs and self._matches(markup, self.text): + found = markup + else: + raise Exception( + "I don't know how to match against a %s" % markup.__class__) + return found + + def _matches(self, markup, match_against): + # print u"Matching %s against %s" % (markup, match_against) + result = False + if isinstance(markup, list) or isinstance(markup, tuple): + # This should only happen when searching a multi-valued attribute + # like 'class'. + if (isinstance(match_against, unicode) + and ' ' in match_against): + # A bit of a special case. If they try to match "foo + # bar" on a multivalue attribute's value, only accept + # the literal value "foo bar" + # + # XXX This is going to be pretty slow because we keep + # splitting match_against. But it shouldn't come up + # too often. + return (whitespace_re.split(match_against) == markup) + else: + for item in markup: + if self._matches(item, match_against): + return True + return False + + if match_against is True: + # True matches any non-None value. + return markup is not None + + if isinstance(match_against, collections.Callable): + return match_against(markup) + + # Custom callables take the tag as an argument, but all + # other ways of matching match the tag name as a string. + if isinstance(markup, Tag): + markup = markup.name + + # Ensure that `markup` is either a Unicode string, or None. + markup = self._normalize_search_value(markup) + + if markup is None: + # None matches None, False, an empty string, an empty list, and so on. + return not match_against + + if isinstance(match_against, unicode): + # Exact string match + return markup == match_against + + if hasattr(match_against, 'match'): + # Regexp match + return match_against.search(markup) + + if hasattr(match_against, '__iter__'): + # The markup must be an exact match against something + # in the iterable. + return markup in match_against + + +class ResultSet(list): + """A ResultSet is just a list that keeps track of the SoupStrainer + that created it.""" + def __init__(self, source): + list.__init__([]) + self.source = source diff --git a/bs4/testing.py b/bs4/testing.py new file mode 100644 index 0000000..5a84b0b --- /dev/null +++ b/bs4/testing.py @@ -0,0 +1,515 @@ +"""Helper classes for tests.""" + +import copy +import functools +import unittest +from unittest import TestCase +from bs4 import BeautifulSoup +from bs4.element import ( + CharsetMetaAttributeValue, + Comment, + ContentMetaAttributeValue, + Doctype, + SoupStrainer, +) + +from bs4.builder import HTMLParserTreeBuilder +default_builder = HTMLParserTreeBuilder + + +class SoupTest(unittest.TestCase): + + @property + def default_builder(self): + return default_builder() + + def soup(self, markup, **kwargs): + """Build a Beautiful Soup object from markup.""" + builder = kwargs.pop('builder', self.default_builder) + return BeautifulSoup(markup, builder=builder, **kwargs) + + def document_for(self, markup): + """Turn an HTML fragment into a document. + + The details depend on the builder. + """ + return self.default_builder.test_fragment_to_document(markup) + + def assertSoupEquals(self, to_parse, compare_parsed_to=None): + builder = self.default_builder + obj = BeautifulSoup(to_parse, builder=builder) + if compare_parsed_to is None: + compare_parsed_to = to_parse + + self.assertEqual(obj.decode(), self.document_for(compare_parsed_to)) + + +class HTMLTreeBuilderSmokeTest(object): + + """A basic test of a treebuilder's competence. + + Any HTML treebuilder, present or future, should be able to pass + these tests. With invalid markup, there's room for interpretation, + and different parsers can handle it differently. But with the + markup in these tests, there's not much room for interpretation. + """ + + def assertDoctypeHandled(self, doctype_fragment): + """Assert that a given doctype string is handled correctly.""" + doctype_str, soup = self._document_with_doctype(doctype_fragment) + + # Make sure a Doctype object was created. + doctype = soup.contents[0] + self.assertEqual(doctype.__class__, Doctype) + self.assertEqual(doctype, doctype_fragment) + self.assertEqual(str(soup)[:len(doctype_str)], doctype_str) + + # Make sure that the doctype was correctly associated with the + # parse tree and that the rest of the document parsed. + self.assertEqual(soup.p.contents[0], 'foo') + + def _document_with_doctype(self, doctype_fragment): + """Generate and parse a document with the given doctype.""" + doctype = '<!DOCTYPE %s>' % doctype_fragment + markup = doctype + '\n<p>foo</p>' + soup = self.soup(markup) + return doctype, soup + + def test_normal_doctypes(self): + """Make sure normal, everyday HTML doctypes are handled correctly.""" + self.assertDoctypeHandled("html") + self.assertDoctypeHandled( + 'html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"') + + def test_public_doctype_with_url(self): + doctype = 'html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"' + self.assertDoctypeHandled(doctype) + + def test_system_doctype(self): + self.assertDoctypeHandled('foo SYSTEM "http://www.example.com/"') + + def test_namespaced_system_doctype(self): + # We can handle a namespaced doctype with a system ID. + self.assertDoctypeHandled('xsl:stylesheet SYSTEM "htmlent.dtd"') + + def test_namespaced_public_doctype(self): + # Test a namespaced doctype with a public id. + self.assertDoctypeHandled('xsl:stylesheet PUBLIC "htmlent.dtd"') + + def test_real_xhtml_document(self): + """A real XHTML document should come out more or less the same as it went in.""" + markup = b"""<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head><title>Hello. +Goodbye. +""" + soup = self.soup(markup) + self.assertEqual( + soup.encode("utf-8").replace(b"\n", b""), + markup.replace(b"\n", b"")) + + def test_deepcopy(self): + """Make sure you can copy the tree builder. + + This is important because the builder is part of a + BeautifulSoup object, and we want to be able to copy that. + """ + copy.deepcopy(self.default_builder) + + def test_p_tag_is_never_empty_element(self): + """A

tag is never designated as an empty-element tag. + + Even if the markup shows it as an empty-element tag, it + shouldn't be presented that way. + """ + soup = self.soup("

") + self.assertFalse(soup.p.is_empty_element) + self.assertEqual(str(soup.p), "

") + + def test_unclosed_tags_get_closed(self): + """A tag that's not closed by the end of the document should be closed. + + This applies to all tags except empty-element tags. + """ + self.assertSoupEquals("

", "

") + self.assertSoupEquals("", "") + + self.assertSoupEquals("
", "
") + + def test_br_is_always_empty_element_tag(self): + """A
tag is designated as an empty-element tag. + + Some parsers treat

as one
tag, some parsers as + two tags, but it should always be an empty-element tag. + """ + soup = self.soup("

") + self.assertTrue(soup.br.is_empty_element) + self.assertEqual(str(soup.br), "
") + + def test_nested_formatting_elements(self): + self.assertSoupEquals("") + + def test_comment(self): + # Comments are represented as Comment objects. + markup = "

foobaz

" + self.assertSoupEquals(markup) + + soup = self.soup(markup) + comment = soup.find(text="foobar") + self.assertEqual(comment.__class__, Comment) + + def test_preserved_whitespace_in_pre_and_textarea(self): + """Whitespace must be preserved in
 and ")
+
+    def test_nested_inline_elements(self):
+        """Inline elements can be nested indefinitely."""
+        b_tag = "Inside a B tag"
+        self.assertSoupEquals(b_tag)
+
+        nested_b_tag = "

A nested tag

" + self.assertSoupEquals(nested_b_tag) + + double_nested_b_tag = "

A doubly nested tag

" + self.assertSoupEquals(nested_b_tag) + + def test_nested_block_level_elements(self): + """Block elements can be nested.""" + soup = self.soup('

Foo

') + blockquote = soup.blockquote + self.assertEqual(blockquote.p.b.string, 'Foo') + self.assertEqual(blockquote.b.string, 'Foo') + + def test_correctly_nested_tables(self): + """One table can go inside another one.""" + markup = ('' + '' + "') + + self.assertSoupEquals( + markup, + '
Here's another table:" + '' + '' + '
foo
Here\'s another table:' + '
foo
' + '
') + + self.assertSoupEquals( + "" + "" + "
Foo
Bar
Baz
") + + def test_angle_brackets_in_attribute_values_are_escaped(self): + self.assertSoupEquals('', '') + + def test_entities_in_attributes_converted_to_unicode(self): + expect = u'

' + self.assertSoupEquals('

', expect) + self.assertSoupEquals('

', expect) + self.assertSoupEquals('

', expect) + + def test_entities_in_text_converted_to_unicode(self): + expect = u'

pi\N{LATIN SMALL LETTER N WITH TILDE}ata

' + self.assertSoupEquals("

piñata

", expect) + self.assertSoupEquals("

piñata

", expect) + self.assertSoupEquals("

piñata

", expect) + + def test_quot_entity_converted_to_quotation_mark(self): + self.assertSoupEquals("

I said "good day!"

", + '

I said "good day!"

') + + def test_out_of_range_entity(self): + expect = u"\N{REPLACEMENT CHARACTER}" + self.assertSoupEquals("�", expect) + self.assertSoupEquals("�", expect) + self.assertSoupEquals("�", expect) + + def test_basic_namespaces(self): + """Parsers don't need to *understand* namespaces, but at the + very least they should not choke on namespaces or lose + data.""" + + markup = b'4' + soup = self.soup(markup) + self.assertEqual(markup, soup.encode()) + html = soup.html + self.assertEqual('http://www.w3.org/1999/xhtml', soup.html['xmlns']) + self.assertEqual( + 'http://www.w3.org/1998/Math/MathML', soup.html['xmlns:mathml']) + self.assertEqual( + 'http://www.w3.org/2000/svg', soup.html['xmlns:svg']) + + def test_multivalued_attribute_value_becomes_list(self): + markup = b'' + soup = self.soup(markup) + self.assertEqual(['foo', 'bar'], soup.a['class']) + + # + # Generally speaking, tests below this point are more tests of + # Beautiful Soup than tests of the tree builders. But parsers are + # weird, so we run these tests separately for every tree builder + # to detect any differences between them. + # + + def test_soupstrainer(self): + """Parsers should be able to work with SoupStrainers.""" + strainer = SoupStrainer("b") + soup = self.soup("A bold statement", + parse_only=strainer) + self.assertEqual(soup.decode(), "bold") + + def test_single_quote_attribute_values_become_double_quotes(self): + self.assertSoupEquals("", + '') + + def test_attribute_values_with_nested_quotes_are_left_alone(self): + text = """a""" + self.assertSoupEquals(text) + + def test_attribute_values_with_double_nested_quotes_get_quoted(self): + text = """a""" + soup = self.soup(text) + soup.foo['attr'] = 'Brawls happen at "Bob\'s Bar"' + self.assertSoupEquals( + soup.foo.decode(), + """a""") + + def test_ampersand_in_attribute_value_gets_escaped(self): + self.assertSoupEquals('', + '') + + self.assertSoupEquals( + 'foo', + 'foo') + + def test_escaped_ampersand_in_attribute_value_is_left_alone(self): + self.assertSoupEquals('') + + def test_entities_in_strings_converted_during_parsing(self): + # Both XML and HTML entities are converted to Unicode characters + # during parsing. + text = "

<<sacré bleu!>>

" + expected = u"

<<sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>>

" + self.assertSoupEquals(text, expected) + + def test_smart_quotes_converted_on_the_way_in(self): + # Microsoft smart quotes are converted to Unicode characters during + # parsing. + quote = b"

\x91Foo\x92

" + soup = self.soup(quote) + self.assertEqual( + soup.p.string, + u"\N{LEFT SINGLE QUOTATION MARK}Foo\N{RIGHT SINGLE QUOTATION MARK}") + + def test_non_breaking_spaces_converted_on_the_way_in(self): + soup = self.soup("  ") + self.assertEqual(soup.a.string, u"\N{NO-BREAK SPACE}" * 2) + + def test_entities_converted_on_the_way_out(self): + text = "

<<sacré bleu!>>

" + expected = u"

<<sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>>

".encode("utf-8") + soup = self.soup(text) + self.assertEqual(soup.p.encode("utf-8"), expected) + + def test_real_iso_latin_document(self): + # Smoke test of interrelated functionality, using an + # easy-to-understand document. + + # Here it is in Unicode. Note that it claims to be in ISO-Latin-1. + unicode_html = u'

Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!

' + + # That's because we're going to encode it into ISO-Latin-1, and use + # that to test. + iso_latin_html = unicode_html.encode("iso-8859-1") + + # Parse the ISO-Latin-1 HTML. + soup = self.soup(iso_latin_html) + # Encode it to UTF-8. + result = soup.encode("utf-8") + + # What do we expect the result to look like? Well, it would + # look like unicode_html, except that the META tag would say + # UTF-8 instead of ISO-Latin-1. + expected = unicode_html.replace("ISO-Latin-1", "utf-8") + + # And, of course, it would be in UTF-8, not Unicode. + expected = expected.encode("utf-8") + + # Ta-da! + self.assertEqual(result, expected) + + def test_real_shift_jis_document(self): + # Smoke test to make sure the parser can handle a document in + # Shift-JIS encoding, without choking. + shift_jis_html = ( + b'
'
+            b'\x82\xb1\x82\xea\x82\xcdShift-JIS\x82\xc5\x83R\x81[\x83f'
+            b'\x83B\x83\x93\x83O\x82\xb3\x82\xea\x82\xbd\x93\xfa\x96{\x8c'
+            b'\xea\x82\xcc\x83t\x83@\x83C\x83\x8b\x82\xc5\x82\xb7\x81B'
+            b'
') + unicode_html = shift_jis_html.decode("shift-jis") + soup = self.soup(unicode_html) + + # Make sure the parse tree is correctly encoded to various + # encodings. + self.assertEqual(soup.encode("utf-8"), unicode_html.encode("utf-8")) + self.assertEqual(soup.encode("euc_jp"), unicode_html.encode("euc_jp")) + + def test_real_hebrew_document(self): + # A real-world test to make sure we can convert ISO-8859-9 (a + # Hebrew encoding) to UTF-8. + hebrew_document = b'Hebrew (ISO 8859-8) in Visual Directionality

Hebrew (ISO 8859-8) in Visual Directionality

\xed\xe5\xec\xf9' + soup = self.soup( + hebrew_document, from_encoding="iso8859-8") + self.assertEqual(soup.original_encoding, 'iso8859-8') + self.assertEqual( + soup.encode('utf-8'), + hebrew_document.decode("iso8859-8").encode("utf-8")) + + def test_meta_tag_reflects_current_encoding(self): + # Here's the tag saying that a document is + # encoded in Shift-JIS. + meta_tag = ('') + + # Here's a document incorporating that meta tag. + shift_jis_html = ( + '\n%s\n' + '' + 'Shift-JIS markup goes here.') % meta_tag + soup = self.soup(shift_jis_html) + + # Parse the document, and the charset is seemingly unaffected. + parsed_meta = soup.find('meta', {'http-equiv': 'Content-type'}) + content = parsed_meta['content'] + self.assertEqual('text/html; charset=x-sjis', content) + + # But that value is actually a ContentMetaAttributeValue object. + self.assertTrue(isinstance(content, ContentMetaAttributeValue)) + + # And it will take on a value that reflects its current + # encoding. + self.assertEqual('text/html; charset=utf8', content.encode("utf8")) + + # For the rest of the story, see TestSubstitutions in + # test_tree.py. + + def test_html5_style_meta_tag_reflects_current_encoding(self): + # Here's the tag saying that a document is + # encoded in Shift-JIS. + meta_tag = ('') + + # Here's a document incorporating that meta tag. + shift_jis_html = ( + '\n%s\n' + '' + 'Shift-JIS markup goes here.') % meta_tag + soup = self.soup(shift_jis_html) + + # Parse the document, and the charset is seemingly unaffected. + parsed_meta = soup.find('meta', id="encoding") + charset = parsed_meta['charset'] + self.assertEqual('x-sjis', charset) + + # But that value is actually a CharsetMetaAttributeValue object. + self.assertTrue(isinstance(charset, CharsetMetaAttributeValue)) + + # And it will take on a value that reflects its current + # encoding. + self.assertEqual('utf8', charset.encode("utf8")) + + def test_tag_with_no_attributes_can_have_attributes_added(self): + data = self.soup("text") + data.a['foo'] = 'bar' + self.assertEqual('text', data.a.decode()) + +class XMLTreeBuilderSmokeTest(object): + + def test_docstring_generated(self): + soup = self.soup("") + self.assertEqual( + soup.encode(), b'\n') + + def test_real_xhtml_document(self): + """A real XHTML document should come out *exactly* the same as it went in.""" + markup = b""" + + +Hello. +Goodbye. +""" + soup = self.soup(markup) + self.assertEqual( + soup.encode("utf-8"), markup) + + + def test_docstring_includes_correct_encoding(self): + soup = self.soup("") + self.assertEqual( + soup.encode("latin1"), + b'\n') + + def test_large_xml_document(self): + """A large XML document should come out the same as it went in.""" + markup = (b'\n' + + b'0' * (2**12) + + b'') + soup = self.soup(markup) + self.assertEqual(soup.encode("utf-8"), markup) + + + def test_tags_are_empty_element_if_and_only_if_they_are_empty(self): + self.assertSoupEquals("

", "

") + self.assertSoupEquals("

foo

") + + def test_namespaces_are_preserved(self): + markup = 'This tag is in the a namespaceThis tag is in the b namespace' + soup = self.soup(markup) + root = soup.root + self.assertEqual("http://example.com/", root['xmlns:a']) + self.assertEqual("http://example.net/", root['xmlns:b']) + + +class HTML5TreeBuilderSmokeTest(HTMLTreeBuilderSmokeTest): + """Smoke test for a tree builder that supports HTML5.""" + + def test_real_xhtml_document(self): + # Since XHTML is not HTML5, HTML5 parsers are not tested to handle + # XHTML documents in any particular way. + pass + + def test_html_tags_have_namespace(self): + markup = "" + soup = self.soup(markup) + self.assertEqual("http://www.w3.org/1999/xhtml", soup.a.namespace) + + def test_svg_tags_have_namespace(self): + markup = '' + soup = self.soup(markup) + namespace = "http://www.w3.org/2000/svg" + self.assertEqual(namespace, soup.svg.namespace) + self.assertEqual(namespace, soup.circle.namespace) + + + def test_mathml_tags_have_namespace(self): + markup = '5' + soup = self.soup(markup) + namespace = 'http://www.w3.org/1998/Math/MathML' + self.assertEqual(namespace, soup.math.namespace) + self.assertEqual(namespace, soup.msqrt.namespace) + + +def skipIf(condition, reason): + def nothing(test, *args, **kwargs): + return None + + def decorator(test_item): + if condition: + return nothing + else: + return test_item + + return decorator diff --git a/bs4/tests/__init__.py b/bs4/tests/__init__.py new file mode 100644 index 0000000..142c8cc --- /dev/null +++ b/bs4/tests/__init__.py @@ -0,0 +1 @@ +"The beautifulsoup tests." diff --git a/bs4/tests/test_builder_registry.py b/bs4/tests/test_builder_registry.py new file mode 100644 index 0000000..92ad10f --- /dev/null +++ b/bs4/tests/test_builder_registry.py @@ -0,0 +1,141 @@ +"""Tests of the builder registry.""" + +import unittest + +from bs4 import BeautifulSoup +from bs4.builder import ( + builder_registry as registry, + HTMLParserTreeBuilder, + TreeBuilderRegistry, +) + +try: + from bs4.builder import HTML5TreeBuilder + HTML5LIB_PRESENT = True +except ImportError: + HTML5LIB_PRESENT = False + +try: + from bs4.builder import ( + LXMLTreeBuilderForXML, + LXMLTreeBuilder, + ) + LXML_PRESENT = True +except ImportError: + LXML_PRESENT = False + + +class BuiltInRegistryTest(unittest.TestCase): + """Test the built-in registry with the default builders registered.""" + + def test_combination(self): + if LXML_PRESENT: + self.assertEqual(registry.lookup('fast', 'html'), + LXMLTreeBuilder) + + if LXML_PRESENT: + self.assertEqual(registry.lookup('permissive', 'xml'), + LXMLTreeBuilderForXML) + self.assertEqual(registry.lookup('strict', 'html'), + HTMLParserTreeBuilder) + if HTML5LIB_PRESENT: + self.assertEqual(registry.lookup('html5lib', 'html'), + HTML5TreeBuilder) + + def test_lookup_by_markup_type(self): + if LXML_PRESENT: + self.assertEqual(registry.lookup('html'), LXMLTreeBuilder) + self.assertEqual(registry.lookup('xml'), LXMLTreeBuilderForXML) + else: + self.assertEqual(registry.lookup('xml'), None) + if HTML5LIB_PRESENT: + self.assertEqual(registry.lookup('html'), HTML5TreeBuilder) + else: + self.assertEqual(registry.lookup('html'), HTMLParserTreeBuilder) + + def test_named_library(self): + if LXML_PRESENT: + self.assertEqual(registry.lookup('lxml', 'xml'), + LXMLTreeBuilderForXML) + self.assertEqual(registry.lookup('lxml', 'html'), + LXMLTreeBuilder) + if HTML5LIB_PRESENT: + self.assertEqual(registry.lookup('html5lib'), + HTML5TreeBuilder) + + self.assertEqual(registry.lookup('html.parser'), + HTMLParserTreeBuilder) + + def test_beautifulsoup_constructor_does_lookup(self): + # You can pass in a string. + BeautifulSoup("", features="html") + # Or a list of strings. + BeautifulSoup("", features=["html", "fast"]) + + # You'll get an exception if BS can't find an appropriate + # builder. + self.assertRaises(ValueError, BeautifulSoup, + "", features="no-such-feature") + +class RegistryTest(unittest.TestCase): + """Test the TreeBuilderRegistry class in general.""" + + def setUp(self): + self.registry = TreeBuilderRegistry() + + def builder_for_features(self, *feature_list): + cls = type('Builder_' + '_'.join(feature_list), + (object,), {'features' : feature_list}) + + self.registry.register(cls) + return cls + + def test_register_with_no_features(self): + builder = self.builder_for_features() + + # Since the builder advertises no features, you can't find it + # by looking up features. + self.assertEqual(self.registry.lookup('foo'), None) + + # But you can find it by doing a lookup with no features, if + # this happens to be the only registered builder. + self.assertEqual(self.registry.lookup(), builder) + + def test_register_with_features_makes_lookup_succeed(self): + builder = self.builder_for_features('foo', 'bar') + self.assertEqual(self.registry.lookup('foo'), builder) + self.assertEqual(self.registry.lookup('bar'), builder) + + def test_lookup_fails_when_no_builder_implements_feature(self): + builder = self.builder_for_features('foo', 'bar') + self.assertEqual(self.registry.lookup('baz'), None) + + def test_lookup_gets_most_recent_registration_when_no_feature_specified(self): + builder1 = self.builder_for_features('foo') + builder2 = self.builder_for_features('bar') + self.assertEqual(self.registry.lookup(), builder2) + + def test_lookup_fails_when_no_tree_builders_registered(self): + self.assertEqual(self.registry.lookup(), None) + + def test_lookup_gets_most_recent_builder_supporting_all_features(self): + has_one = self.builder_for_features('foo') + has_the_other = self.builder_for_features('bar') + has_both_early = self.builder_for_features('foo', 'bar', 'baz') + has_both_late = self.builder_for_features('foo', 'bar', 'quux') + lacks_one = self.builder_for_features('bar') + has_the_other = self.builder_for_features('foo') + + # There are two builders featuring 'foo' and 'bar', but + # the one that also features 'quux' was registered later. + self.assertEqual(self.registry.lookup('foo', 'bar'), + has_both_late) + + # There is only one builder featuring 'foo', 'bar', and 'baz'. + self.assertEqual(self.registry.lookup('foo', 'bar', 'baz'), + has_both_early) + + def test_lookup_fails_when_cannot_reconcile_requested_features(self): + builder1 = self.builder_for_features('foo', 'bar') + builder2 = self.builder_for_features('foo', 'baz') + self.assertEqual(self.registry.lookup('bar', 'baz'), None) diff --git a/bs4/tests/test_docs.py b/bs4/tests/test_docs.py new file mode 100644 index 0000000..5b9f677 --- /dev/null +++ b/bs4/tests/test_docs.py @@ -0,0 +1,36 @@ +"Test harness for doctests." + +# pylint: disable-msg=E0611,W0142 + +__metaclass__ = type +__all__ = [ + 'additional_tests', + ] + +import atexit +import doctest +import os +#from pkg_resources import ( +# resource_filename, resource_exists, resource_listdir, cleanup_resources) +import unittest + +DOCTEST_FLAGS = ( + doctest.ELLIPSIS | + doctest.NORMALIZE_WHITESPACE | + doctest.REPORT_NDIFF) + + +# def additional_tests(): +# "Run the doc tests (README.txt and docs/*, if any exist)" +# doctest_files = [ +# os.path.abspath(resource_filename('bs4', 'README.txt'))] +# if resource_exists('bs4', 'docs'): +# for name in resource_listdir('bs4', 'docs'): +# if name.endswith('.txt'): +# doctest_files.append( +# os.path.abspath( +# resource_filename('bs4', 'docs/%s' % name))) +# kwargs = dict(module_relative=False, optionflags=DOCTEST_FLAGS) +# atexit.register(cleanup_resources) +# return unittest.TestSuite(( +# doctest.DocFileSuite(*doctest_files, **kwargs))) diff --git a/bs4/tests/test_html5lib.py b/bs4/tests/test_html5lib.py new file mode 100644 index 0000000..f195f7d --- /dev/null +++ b/bs4/tests/test_html5lib.py @@ -0,0 +1,58 @@ +"""Tests to ensure that the html5lib tree builder generates good trees.""" + +import warnings + +try: + from bs4.builder import HTML5TreeBuilder + HTML5LIB_PRESENT = True +except ImportError, e: + HTML5LIB_PRESENT = False +from bs4.element import SoupStrainer +from bs4.testing import ( + HTML5TreeBuilderSmokeTest, + SoupTest, + skipIf, +) + +@skipIf( + not HTML5LIB_PRESENT, + "html5lib seems not to be present, not testing its tree builder.") +class HTML5LibBuilderSmokeTest(SoupTest, HTML5TreeBuilderSmokeTest): + """See ``HTML5TreeBuilderSmokeTest``.""" + + @property + def default_builder(self): + return HTML5TreeBuilder() + + def test_soupstrainer(self): + # The html5lib tree builder does not support SoupStrainers. + strainer = SoupStrainer("b") + markup = "

A bold statement.

" + with warnings.catch_warnings(record=True) as w: + soup = self.soup(markup, parse_only=strainer) + self.assertEqual( + soup.decode(), self.document_for(markup)) + + self.assertTrue( + "the html5lib tree builder doesn't support parse_only" in + str(w[0].message)) + + def test_correctly_nested_tables(self): + """html5lib inserts tags where other parsers don't.""" + markup = ('' + '' + "') + + self.assertSoupEquals( + markup, + '
Here's another table:" + '' + '' + '
foo
Here\'s another table:' + '
foo
' + '
') + + self.assertSoupEquals( + "" + "" + "
Foo
Bar
Baz
") diff --git a/bs4/tests/test_htmlparser.py b/bs4/tests/test_htmlparser.py new file mode 100644 index 0000000..bcb5ed2 --- /dev/null +++ b/bs4/tests/test_htmlparser.py @@ -0,0 +1,19 @@ +"""Tests to ensure that the html.parser tree builder generates good +trees.""" + +from bs4.testing import SoupTest, HTMLTreeBuilderSmokeTest +from bs4.builder import HTMLParserTreeBuilder + +class HTMLParserTreeBuilderSmokeTest(SoupTest, HTMLTreeBuilderSmokeTest): + + @property + def default_builder(self): + return HTMLParserTreeBuilder() + + def test_namespaced_system_doctype(self): + # html.parser can't handle namespaced doctypes, so skip this one. + pass + + def test_namespaced_public_doctype(self): + # html.parser can't handle namespaced doctypes, so skip this one. + pass diff --git a/bs4/tests/test_lxml.py b/bs4/tests/test_lxml.py new file mode 100644 index 0000000..39e26bf --- /dev/null +++ b/bs4/tests/test_lxml.py @@ -0,0 +1,75 @@ +"""Tests to ensure that the lxml tree builder generates good trees.""" + +import re +import warnings + +try: + from bs4.builder import LXMLTreeBuilder, LXMLTreeBuilderForXML + LXML_PRESENT = True +except ImportError, e: + LXML_PRESENT = False + +from bs4 import ( + BeautifulSoup, + BeautifulStoneSoup, + ) +from bs4.element import Comment, Doctype, SoupStrainer +from bs4.testing import skipIf +from bs4.tests import test_htmlparser +from bs4.testing import ( + HTMLTreeBuilderSmokeTest, + XMLTreeBuilderSmokeTest, + SoupTest, + skipIf, +) + +@skipIf( + not LXML_PRESENT, + "lxml seems not to be present, not testing its tree builder.") +class LXMLTreeBuilderSmokeTest(SoupTest, HTMLTreeBuilderSmokeTest): + """See ``HTMLTreeBuilderSmokeTest``.""" + + @property + def default_builder(self): + return LXMLTreeBuilder() + + def test_out_of_range_entity(self): + self.assertSoupEquals( + "

foo�bar

", "

foobar

") + self.assertSoupEquals( + "

foo�bar

", "

foobar

") + self.assertSoupEquals( + "

foo�bar

", "

foobar

") + + def test_beautifulstonesoup_is_xml_parser(self): + # Make sure that the deprecated BSS class uses an xml builder + # if one is installed. + with warnings.catch_warnings(record=False) as w: + soup = BeautifulStoneSoup("") + self.assertEqual(u"", unicode(soup.b)) + + def test_real_xhtml_document(self): + """lxml strips the XML definition from an XHTML doc, which is fine.""" + markup = b""" + + +Hello. +Goodbye. +""" + soup = self.soup(markup) + self.assertEqual( + soup.encode("utf-8").replace(b"\n", b''), + markup.replace(b'\n', b'').replace( + b'', b'')) + + +@skipIf( + not LXML_PRESENT, + "lxml seems not to be present, not testing its XML tree builder.") +class LXMLXMLTreeBuilderSmokeTest(SoupTest, XMLTreeBuilderSmokeTest): + """See ``HTMLTreeBuilderSmokeTest``.""" + + @property + def default_builder(self): + return LXMLTreeBuilderForXML() + diff --git a/bs4/tests/test_soup.py b/bs4/tests/test_soup.py new file mode 100644 index 0000000..23a664e --- /dev/null +++ b/bs4/tests/test_soup.py @@ -0,0 +1,368 @@ +# -*- coding: utf-8 -*- +"""Tests of Beautiful Soup as a whole.""" + +import unittest +from bs4 import ( + BeautifulSoup, + BeautifulStoneSoup, +) +from bs4.element import ( + CharsetMetaAttributeValue, + ContentMetaAttributeValue, + SoupStrainer, + NamespacedAttribute, + ) +import bs4.dammit +from bs4.dammit import EntitySubstitution, UnicodeDammit +from bs4.testing import ( + SoupTest, + skipIf, +) +import warnings + +try: + from bs4.builder import LXMLTreeBuilder, LXMLTreeBuilderForXML + LXML_PRESENT = True +except ImportError, e: + LXML_PRESENT = False + +class TestDeprecatedConstructorArguments(SoupTest): + + def test_parseOnlyThese_renamed_to_parse_only(self): + with warnings.catch_warnings(record=True) as w: + soup = self.soup("
", parseOnlyThese=SoupStrainer("b")) + msg = str(w[0].message) + self.assertTrue("parseOnlyThese" in msg) + self.assertTrue("parse_only" in msg) + self.assertEqual(b"", soup.encode()) + + def test_fromEncoding_renamed_to_from_encoding(self): + with warnings.catch_warnings(record=True) as w: + utf8 = b"\xc3\xa9" + soup = self.soup(utf8, fromEncoding="utf8") + msg = str(w[0].message) + self.assertTrue("fromEncoding" in msg) + self.assertTrue("from_encoding" in msg) + self.assertEqual("utf8", soup.original_encoding) + + def test_unrecognized_keyword_argument(self): + self.assertRaises( + TypeError, self.soup, "", no_such_argument=True) + + @skipIf( + not LXML_PRESENT, + "lxml not present, not testing BeautifulStoneSoup.") + def test_beautifulstonesoup(self): + with warnings.catch_warnings(record=True) as w: + soup = BeautifulStoneSoup("") + self.assertTrue(isinstance(soup, BeautifulSoup)) + self.assertTrue("BeautifulStoneSoup class is deprecated") + +class TestSelectiveParsing(SoupTest): + + def test_parse_with_soupstrainer(self): + markup = "NoYesNoYes Yes" + strainer = SoupStrainer("b") + soup = self.soup(markup, parse_only=strainer) + self.assertEqual(soup.encode(), b"YesYes Yes") + + +class TestEntitySubstitution(unittest.TestCase): + """Standalone tests of the EntitySubstitution class.""" + def setUp(self): + self.sub = EntitySubstitution + + def test_simple_html_substitution(self): + # Unicode characters corresponding to named HTML entites + # are substituted, and no others. + s = u"foo\u2200\N{SNOWMAN}\u00f5bar" + self.assertEqual(self.sub.substitute_html(s), + u"foo∀\N{SNOWMAN}õbar") + + def test_smart_quote_substitution(self): + # MS smart quotes are a common source of frustration, so we + # give them a special test. + quotes = b"\x91\x92foo\x93\x94" + dammit = UnicodeDammit(quotes) + self.assertEqual(self.sub.substitute_html(dammit.markup), + "‘’foo“”") + + def test_xml_converstion_includes_no_quotes_if_make_quoted_attribute_is_false(self): + s = 'Welcome to "my bar"' + self.assertEqual(self.sub.substitute_xml(s, False), s) + + def test_xml_attribute_quoting_normally_uses_double_quotes(self): + self.assertEqual(self.sub.substitute_xml("Welcome", True), + '"Welcome"') + self.assertEqual(self.sub.substitute_xml("Bob's Bar", True), + '"Bob\'s Bar"') + + def test_xml_attribute_quoting_uses_single_quotes_when_value_contains_double_quotes(self): + s = 'Welcome to "my bar"' + self.assertEqual(self.sub.substitute_xml(s, True), + "'Welcome to \"my bar\"'") + + def test_xml_attribute_quoting_escapes_single_quotes_when_value_contains_both_single_and_double_quotes(self): + s = 'Welcome to "Bob\'s Bar"' + self.assertEqual( + self.sub.substitute_xml(s, True), + '"Welcome to "Bob\'s Bar""') + + def test_xml_quotes_arent_escaped_when_value_is_not_being_quoted(self): + quoted = 'Welcome to "Bob\'s Bar"' + self.assertEqual(self.sub.substitute_xml(quoted), quoted) + + def test_xml_quoting_handles_angle_brackets(self): + self.assertEqual( + self.sub.substitute_xml("foo"), + "foo<bar>") + + def test_xml_quoting_handles_ampersands(self): + self.assertEqual(self.sub.substitute_xml("AT&T"), "AT&T") + + def test_xml_quoting_ignores_ampersands_when_they_are_part_of_an_entity(self): + self.assertEqual( + self.sub.substitute_xml("ÁT&T"), + "ÁT&T") + + def test_quotes_not_html_substituted(self): + """There's no need to do this except inside attribute values.""" + text = 'Bob\'s "bar"' + self.assertEqual(self.sub.substitute_html(text), text) + + +class TestEncodingConversion(SoupTest): + # Test Beautiful Soup's ability to decode and encode from various + # encodings. + + def setUp(self): + super(TestEncodingConversion, self).setUp() + self.unicode_data = u"Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!" + self.utf8_data = self.unicode_data.encode("utf-8") + # Just so you know what it looks like. + self.assertEqual( + self.utf8_data, + b"Sacr\xc3\xa9 bleu!") + + def test_ascii_in_unicode_out(self): + # ASCII input is converted to Unicode. The original_encoding + # attribute is set. + ascii = b"a" + soup_from_ascii = self.soup(ascii) + unicode_output = soup_from_ascii.decode() + self.assertTrue(isinstance(unicode_output, unicode)) + self.assertEqual(unicode_output, self.document_for(ascii.decode())) + self.assertEqual(soup_from_ascii.original_encoding, "ascii") + + def test_unicode_in_unicode_out(self): + # Unicode input is left alone. The original_encoding attribute + # is not set. + soup_from_unicode = self.soup(self.unicode_data) + self.assertEqual(soup_from_unicode.decode(), self.unicode_data) + self.assertEqual(soup_from_unicode.foo.string, u'Sacr\xe9 bleu!') + self.assertEqual(soup_from_unicode.original_encoding, None) + + def test_utf8_in_unicode_out(self): + # UTF-8 input is converted to Unicode. The original_encoding + # attribute is set. + soup_from_utf8 = self.soup(self.utf8_data) + self.assertEqual(soup_from_utf8.decode(), self.unicode_data) + self.assertEqual(soup_from_utf8.foo.string, u'Sacr\xe9 bleu!') + + def test_utf8_out(self): + # The internal data structures can be encoded as UTF-8. + soup_from_unicode = self.soup(self.unicode_data) + self.assertEqual(soup_from_unicode.encode('utf-8'), self.utf8_data) + + +class TestUnicodeDammit(unittest.TestCase): + """Standalone tests of Unicode, Dammit.""" + + def test_smart_quotes_to_unicode(self): + markup = b"\x91\x92\x93\x94" + dammit = UnicodeDammit(markup) + self.assertEqual( + dammit.unicode_markup, u"\u2018\u2019\u201c\u201d") + + def test_smart_quotes_to_xml_entities(self): + markup = b"\x91\x92\x93\x94" + dammit = UnicodeDammit(markup, smart_quotes_to="xml") + self.assertEqual( + dammit.unicode_markup, "‘’“”") + + def test_smart_quotes_to_html_entities(self): + markup = b"\x91\x92\x93\x94" + dammit = UnicodeDammit(markup, smart_quotes_to="html") + self.assertEqual( + dammit.unicode_markup, "‘’“”") + + def test_smart_quotes_to_ascii(self): + markup = b"\x91\x92\x93\x94" + dammit = UnicodeDammit(markup, smart_quotes_to="ascii") + self.assertEqual( + dammit.unicode_markup, """''""""") + + def test_detect_utf8(self): + utf8 = b"\xc3\xa9" + dammit = UnicodeDammit(utf8) + self.assertEqual(dammit.unicode_markup, u'\xe9') + self.assertEqual(dammit.original_encoding, 'utf-8') + + def test_convert_hebrew(self): + hebrew = b"\xed\xe5\xec\xf9" + dammit = UnicodeDammit(hebrew, ["iso-8859-8"]) + self.assertEqual(dammit.original_encoding, 'iso-8859-8') + self.assertEqual(dammit.unicode_markup, u'\u05dd\u05d5\u05dc\u05e9') + + def test_dont_see_smart_quotes_where_there_are_none(self): + utf_8 = b"\343\202\261\343\203\274\343\202\277\343\202\244 Watch" + dammit = UnicodeDammit(utf_8) + self.assertEqual(dammit.original_encoding, 'utf-8') + self.assertEqual(dammit.unicode_markup.encode("utf-8"), utf_8) + + def test_ignore_inappropriate_codecs(self): + utf8_data = u"Räksmörgås".encode("utf-8") + dammit = UnicodeDammit(utf8_data, ["iso-8859-8"]) + self.assertEqual(dammit.original_encoding, 'utf-8') + + def test_ignore_invalid_codecs(self): + utf8_data = u"Räksmörgås".encode("utf-8") + for bad_encoding in ['.utf8', '...', 'utF---16.!']: + dammit = UnicodeDammit(utf8_data, [bad_encoding]) + self.assertEqual(dammit.original_encoding, 'utf-8') + + def test_detect_html5_style_meta_tag(self): + + for data in ( + b'', + b"", + b"", + b""): + dammit = UnicodeDammit(data, is_html=True) + self.assertEqual( + "euc-jp", dammit.original_encoding) + + def test_last_ditch_entity_replacement(self): + # This is a UTF-8 document that contains bytestrings + # completely incompatible with UTF-8 (ie. encoded with some other + # encoding). + # + # Since there is no consistent encoding for the document, + # Unicode, Dammit will eventually encode the document as UTF-8 + # and encode the incompatible characters as REPLACEMENT + # CHARACTER. + # + # If chardet is installed, it will detect that the document + # can be converted into ISO-8859-1 without errors. This happens + # to be the wrong encoding, but it is a consistent encoding, so the + # code we're testing here won't run. + # + # So we temporarily disable chardet if it's present. + doc = b"""\357\273\277 +\330\250\330\252\330\261 +\310\322\321\220\312\321\355\344""" + chardet = bs4.dammit.chardet + try: + bs4.dammit.chardet = None + with warnings.catch_warnings(record=True) as w: + dammit = UnicodeDammit(doc) + self.assertEqual(True, dammit.contains_replacement_characters) + self.assertTrue(u"\ufffd" in dammit.unicode_markup) + + soup = BeautifulSoup(doc, "html.parser") + self.assertTrue(soup.contains_replacement_characters) + + msg = w[0].message + self.assertTrue(isinstance(msg, UnicodeWarning)) + self.assertTrue("Some characters could not be decoded" in str(msg)) + finally: + bs4.dammit.chardet = chardet + + def test_sniffed_xml_encoding(self): + # A document written in UTF-16LE will be converted by a different + # code path that sniffs the byte order markers. + data = b'\xff\xfe<\x00a\x00>\x00\xe1\x00\xe9\x00<\x00/\x00a\x00>\x00' + dammit = UnicodeDammit(data) + self.assertEqual(u"áé", dammit.unicode_markup) + self.assertEqual("utf-16le", dammit.original_encoding) + + def test_detwingle(self): + # Here's a UTF8 document. + utf8 = (u"\N{SNOWMAN}" * 3).encode("utf8") + + # Here's a Windows-1252 document. + windows_1252 = ( + u"\N{LEFT DOUBLE QUOTATION MARK}Hi, I like Windows!" + u"\N{RIGHT DOUBLE QUOTATION MARK}").encode("windows_1252") + + # Through some unholy alchemy, they've been stuck together. + doc = utf8 + windows_1252 + utf8 + + # The document can't be turned into UTF-8: + self.assertRaises(UnicodeDecodeError, doc.decode, "utf8") + + # Unicode, Dammit thinks the whole document is Windows-1252, + # and decodes it into "☃☃☃“Hi, I like Windows!”☃☃☃" + + # But if we run it through fix_embedded_windows_1252, it's fixed: + + fixed = UnicodeDammit.detwingle(doc) + self.assertEqual( + u"☃☃☃“Hi, I like Windows!”☃☃☃", fixed.decode("utf8")) + + def test_detwingle_ignores_multibyte_characters(self): + # Each of these characters has a UTF-8 representation ending + # in \x93. \x93 is a smart quote if interpreted as + # Windows-1252. But our code knows to skip over multibyte + # UTF-8 characters, so they'll survive the process unscathed. + for tricky_unicode_char in ( + u"\N{LATIN SMALL LIGATURE OE}", # 2-byte char '\xc5\x93' + u"\N{LATIN SUBSCRIPT SMALL LETTER X}", # 3-byte char '\xe2\x82\x93' + u"\xf0\x90\x90\x93", # This is a CJK character, not sure which one. + ): + input = tricky_unicode_char.encode("utf8") + self.assertTrue(input.endswith(b'\x93')) + output = UnicodeDammit.detwingle(input) + self.assertEqual(output, input) + +class TestNamedspacedAttribute(SoupTest): + + def test_name_may_be_none(self): + a = NamespacedAttribute("xmlns", None) + self.assertEqual(a, "xmlns") + + def test_attribute_is_equivalent_to_colon_separated_string(self): + a = NamespacedAttribute("a", "b") + self.assertEqual("a:b", a) + + def test_attributes_are_equivalent_if_prefix_and_name_identical(self): + a = NamespacedAttribute("a", "b", "c") + b = NamespacedAttribute("a", "b", "c") + self.assertEqual(a, b) + + # The actual namespace is not considered. + c = NamespacedAttribute("a", "b", None) + self.assertEqual(a, c) + + # But name and prefix are important. + d = NamespacedAttribute("a", "z", "c") + self.assertNotEqual(a, d) + + e = NamespacedAttribute("z", "b", "c") + self.assertNotEqual(a, e) + + +class TestAttributeValueWithCharsetSubstitution(unittest.TestCase): + + def test_content_meta_attribute_value(self): + value = CharsetMetaAttributeValue("euc-jp") + self.assertEqual("euc-jp", value) + self.assertEqual("euc-jp", value.original_value) + self.assertEqual("utf8", value.encode("utf8")) + + + def test_content_meta_attribute_value(self): + value = ContentMetaAttributeValue("text/html; charset=euc-jp") + self.assertEqual("text/html; charset=euc-jp", value) + self.assertEqual("text/html; charset=euc-jp", value.original_value) + self.assertEqual("text/html; charset=utf8", value.encode("utf8")) diff --git a/bs4/tests/test_tree.py b/bs4/tests/test_tree.py new file mode 100644 index 0000000..cc573ed --- /dev/null +++ b/bs4/tests/test_tree.py @@ -0,0 +1,1695 @@ +# -*- coding: utf-8 -*- +"""Tests for Beautiful Soup's tree traversal methods. + +The tree traversal methods are the main advantage of using Beautiful +Soup over just using a parser. + +Different parsers will build different Beautiful Soup trees given the +same markup, but all Beautiful Soup trees can be traversed with the +methods tested here. +""" + +import copy +import pickle +import re +import warnings +from bs4 import BeautifulSoup +from bs4.builder import ( + builder_registry, + HTMLParserTreeBuilder, +) +from bs4.element import ( + CData, + Doctype, + NavigableString, + SoupStrainer, + Tag, +) +from bs4.testing import ( + SoupTest, + skipIf, +) + +XML_BUILDER_PRESENT = (builder_registry.lookup("xml") is not None) +LXML_PRESENT = (builder_registry.lookup("lxml") is not None) + +class TreeTest(SoupTest): + + def assertSelects(self, tags, should_match): + """Make sure that the given tags have the correct text. + + This is used in tests that define a bunch of tags, each + containing a single string, and then select certain strings by + some mechanism. + """ + self.assertEqual([tag.string for tag in tags], should_match) + + def assertSelectsIDs(self, tags, should_match): + """Make sure that the given tags have the correct IDs. + + This is used in tests that define a bunch of tags, each + containing a single string, and then select certain strings by + some mechanism. + """ + self.assertEqual([tag['id'] for tag in tags], should_match) + + +class TestFind(TreeTest): + """Basic tests of the find() method. + + find() just calls find_all() with limit=1, so it's not tested all + that thouroughly here. + """ + + def test_find_tag(self): + soup = self.soup("1234") + self.assertEqual(soup.find("b").string, "2") + + def test_unicode_text_find(self): + soup = self.soup(u'

Räksmörgås

') + self.assertEqual(soup.find(text=u'Räksmörgås'), u'Räksmörgås') + +class TestFindAll(TreeTest): + """Basic tests of the find_all() method.""" + + def test_find_all_text_nodes(self): + """You can search the tree for text nodes.""" + soup = self.soup("Foobar\xbb") + # Exact match. + self.assertEqual(soup.find_all(text="bar"), [u"bar"]) + # Match any of a number of strings. + self.assertEqual( + soup.find_all(text=["Foo", "bar"]), [u"Foo", u"bar"]) + # Match a regular expression. + self.assertEqual(soup.find_all(text=re.compile('.*')), + [u"Foo", u"bar", u'\xbb']) + # Match anything. + self.assertEqual(soup.find_all(text=True), + [u"Foo", u"bar", u'\xbb']) + + def test_find_all_limit(self): + """You can limit the number of items returned by find_all.""" + soup = self.soup("12345") + self.assertSelects(soup.find_all('a', limit=3), ["1", "2", "3"]) + self.assertSelects(soup.find_all('a', limit=1), ["1"]) + self.assertSelects( + soup.find_all('a', limit=10), ["1", "2", "3", "4", "5"]) + + # A limit of 0 means no limit. + self.assertSelects( + soup.find_all('a', limit=0), ["1", "2", "3", "4", "5"]) + + def test_calling_a_tag_is_calling_findall(self): + soup = self.soup("123") + self.assertSelects(soup('a', limit=1), ["1"]) + self.assertSelects(soup.b(id="foo"), ["3"]) + + def test_find_all_with_self_referential_data_structure_does_not_cause_infinite_recursion(self): + soup = self.soup("") + # Create a self-referential list. + l = [] + l.append(l) + + # Without special code in _normalize_search_value, this would cause infinite + # recursion. + self.assertEqual([], soup.find_all(l)) + +class TestFindAllBasicNamespaces(TreeTest): + + def test_find_by_namespaced_name(self): + soup = self.soup('4') + self.assertEqual("4", soup.find("mathml:msqrt").string) + self.assertEqual("a", soup.find(attrs= { "svg:fill" : "red" }).name) + + +class TestFindAllByName(TreeTest): + """Test ways of finding tags by tag name.""" + + def setUp(self): + super(TreeTest, self).setUp() + self.tree = self.soup("""First tag. + Second tag. + Third Nested tag. tag.""") + + def test_find_all_by_tag_name(self): + # Find all the tags. + self.assertSelects( + self.tree.find_all('a'), ['First tag.', 'Nested tag.']) + + def test_find_all_by_name_and_text(self): + self.assertSelects( + self.tree.find_all('a', text='First tag.'), ['First tag.']) + + self.assertSelects( + self.tree.find_all('a', text=True), ['First tag.', 'Nested tag.']) + + self.assertSelects( + self.tree.find_all('a', text=re.compile("tag")), + ['First tag.', 'Nested tag.']) + + + def test_find_all_on_non_root_element(self): + # You can call find_all on any node, not just the root. + self.assertSelects(self.tree.c.find_all('a'), ['Nested tag.']) + + def test_calling_element_invokes_find_all(self): + self.assertSelects(self.tree('a'), ['First tag.', 'Nested tag.']) + + def test_find_all_by_tag_strainer(self): + self.assertSelects( + self.tree.find_all(SoupStrainer('a')), + ['First tag.', 'Nested tag.']) + + def test_find_all_by_tag_names(self): + self.assertSelects( + self.tree.find_all(['a', 'b']), + ['First tag.', 'Second tag.', 'Nested tag.']) + + def test_find_all_by_tag_dict(self): + self.assertSelects( + self.tree.find_all({'a' : True, 'b' : True}), + ['First tag.', 'Second tag.', 'Nested tag.']) + + def test_find_all_by_tag_re(self): + self.assertSelects( + self.tree.find_all(re.compile('^[ab]$')), + ['First tag.', 'Second tag.', 'Nested tag.']) + + def test_find_all_with_tags_matching_method(self): + # You can define an oracle method that determines whether + # a tag matches the search. + def id_matches_name(tag): + return tag.name == tag.get('id') + + tree = self.soup("""Match 1. + Does not match. + Match 2.""") + + self.assertSelects( + tree.find_all(id_matches_name), ["Match 1.", "Match 2."]) + + +class TestFindAllByAttribute(TreeTest): + + def test_find_all_by_attribute_name(self): + # You can pass in keyword arguments to find_all to search by + # attribute. + tree = self.soup(""" + Matching a. + + Non-matching Matching b.a. + """) + self.assertSelects(tree.find_all(id='first'), + ["Matching a.", "Matching b."]) + + def test_find_all_by_utf8_attribute_value(self): + peace = u"םולש".encode("utf8") + data = u''.encode("utf8") + soup = self.soup(data) + self.assertEqual([soup.a], soup.find_all(title=peace)) + self.assertEqual([soup.a], soup.find_all(title=peace.decode("utf8"))) + self.assertEqual([soup.a], soup.find_all(title=[peace, "something else"])) + + def test_find_all_by_attribute_dict(self): + # You can pass in a dictionary as the argument 'attrs'. This + # lets you search for attributes like 'name' (a fixed argument + # to find_all) and 'class' (a reserved word in Python.) + tree = self.soup(""" + Name match. + Class match. + Non-match. + A tag called 'name1'. + """) + + # This doesn't do what you want. + self.assertSelects(tree.find_all(name='name1'), + ["A tag called 'name1'."]) + # This does what you want. + self.assertSelects(tree.find_all(attrs={'name' : 'name1'}), + ["Name match."]) + + # Passing class='class2' would cause a syntax error. + self.assertSelects(tree.find_all(attrs={'class' : 'class2'}), + ["Class match."]) + + def test_find_all_by_class(self): + # Passing in a string to 'attrs' will search the CSS class. + tree = self.soup(""" + Class 1. + Class 2. + Class 1. + Class 3 and 4. + """) + self.assertSelects(tree.find_all('a', '1'), ['Class 1.']) + self.assertSelects(tree.find_all(attrs='1'), ['Class 1.', 'Class 1.']) + self.assertSelects(tree.find_all('c', '3'), ['Class 3 and 4.']) + self.assertSelects(tree.find_all('c', '4'), ['Class 3 and 4.']) + + def test_find_by_class_when_multiple_classes_present(self): + tree = self.soup("Found it") + + attrs = { 'class' : re.compile("o") } + f = tree.find_all("gar", attrs=attrs) + self.assertSelects(f, ["Found it"]) + + f = tree.find_all("gar", re.compile("a")) + self.assertSelects(f, ["Found it"]) + + # Since the class is not the string "foo bar", but the two + # strings "foo" and "bar", this will not find anything. + attrs = { 'class' : re.compile("o b") } + f = tree.find_all("gar", attrs=attrs) + self.assertSelects(f, []) + + def test_find_all_with_non_dictionary_for_attrs_finds_by_class(self): + soup = self.soup("Found it") + + self.assertSelects(soup.find_all("a", re.compile("ba")), ["Found it"]) + + def big_attribute_value(value): + return len(value) > 3 + + self.assertSelects(soup.find_all("a", big_attribute_value), []) + + def small_attribute_value(value): + return len(value) <= 3 + + self.assertSelects( + soup.find_all("a", small_attribute_value), ["Found it"]) + + def test_find_all_with_string_for_attrs_finds_multiple_classes(self): + soup = self.soup('') + a, a2 = soup.find_all("a") + self.assertEqual([a, a2], soup.find_all("a", "foo")) + self.assertEqual([a], soup.find_all("a", "bar")) + + # If you specify the attribute as a string that contains a + # space, only that specific value will be found. + self.assertEqual([a], soup.find_all("a", "foo bar")) + self.assertEqual([], soup.find_all("a", "bar foo")) + + def test_find_all_by_attribute_soupstrainer(self): + tree = self.soup(""" + Match. + Non-match.""") + + strainer = SoupStrainer(attrs={'id' : 'first'}) + self.assertSelects(tree.find_all(strainer), ['Match.']) + + def test_find_all_with_missing_atribute(self): + # You can pass in None as the value of an attribute to find_all. + # This will match tags that do not have that attribute set. + tree = self.soup("""ID present. + No ID present. + ID is empty.""") + self.assertSelects(tree.find_all('a', id=None), ["No ID present."]) + + def test_find_all_with_defined_attribute(self): + # You can pass in None as the value of an attribute to find_all. + # This will match tags that have that attribute set to any value. + tree = self.soup("""ID present. + No ID present. + ID is empty.""") + self.assertSelects( + tree.find_all(id=True), ["ID present.", "ID is empty."]) + + def test_find_all_with_numeric_attribute(self): + # If you search for a number, it's treated as a string. + tree = self.soup("""Unquoted attribute. + Quoted attribute.""") + + expected = ["Unquoted attribute.", "Quoted attribute."] + self.assertSelects(tree.find_all(id=1), expected) + self.assertSelects(tree.find_all(id="1"), expected) + + def test_find_all_with_list_attribute_values(self): + # You can pass a list of attribute values instead of just one, + # and you'll get tags that match any of the values. + tree = self.soup("""1 + 2 + 3 + No ID.""") + self.assertSelects(tree.find_all(id=["1", "3", "4"]), + ["1", "3"]) + + def test_find_all_with_regular_expression_attribute_value(self): + # You can pass a regular expression as an attribute value, and + # you'll get tags whose values for that attribute match the + # regular expression. + tree = self.soup("""One a. + Two as. + Mixed as and bs. + One b. + No ID.""") + + self.assertSelects(tree.find_all(id=re.compile("^a+$")), + ["One a.", "Two as."]) + + def test_find_by_name_and_containing_string(self): + soup = self.soup("foobarfoo") + a = soup.a + + self.assertEqual([a], soup.find_all("a", text="foo")) + self.assertEqual([], soup.find_all("a", text="bar")) + self.assertEqual([], soup.find_all("a", text="bar")) + + def test_find_by_name_and_containing_string_when_string_is_buried(self): + soup = self.soup("foofoo") + self.assertEqual(soup.find_all("a"), soup.find_all("a", text="foo")) + + def test_find_by_attribute_and_containing_string(self): + soup = self.soup('foofoo') + a = soup.a + + self.assertEqual([a], soup.find_all(id=2, text="foo")) + self.assertEqual([], soup.find_all(id=1, text="bar")) + + + + +class TestIndex(TreeTest): + """Test Tag.index""" + def test_index(self): + tree = self.soup("""
+ Identical + Not identical + Identical + + Identical with child + Also not identical + Identical with child +
""") + div = tree.div + for i, element in enumerate(div.contents): + self.assertEqual(i, div.index(element)) + self.assertRaises(ValueError, tree.index, 1) + + +class TestParentOperations(TreeTest): + """Test navigation and searching through an element's parents.""" + + def setUp(self): + super(TestParentOperations, self).setUp() + self.tree = self.soup('''
    +