예제 #1
0
def main(delay):
    from PyQt4.QtCore import QTimer
    app = AnkiApp(sys.argv)
    QCoreApplication.setApplicationName("Anki")

    if app.secondInstance():
        print('Anki is already running')
        return

    from aqt.profiles import ProfileManager
    pm = ProfileManager('', '')
    setupLang(pm, app)
    pm.load('bz')
    pm.profile['autoSync'] = True
    pm.ensureProfile()

    def dummy(*args, **kwargs):
        pass

    # load the main window
    import aqt.main
    mw = aqt.main.AnkiQt(app, pm, [])

    # prevent Anki from showing main window
    mw.show = dummy
    mw.activateWindow = dummy
    mw.raise_ = dummy

    def handler():
        if mw.state == 'sync':
            # print('state sync')
            set_timer()
            return
        else:
            # print('state NOT sync')
            mw.onClose()
            mw.close()
            app.closeAllWindows()
            app.quit()

    def set_timer():
        timer = QTimer(mw)
        timer.setSingleShot(True)
        timer.connect(timer, SIGNAL('timeout()'), handler)
        timer.start(delay)

    def start_sync():
        # print('timer start_sync')
        mw.onSync(auto=True)

    set_timer()
    QTimer.singleShot(5000, start_sync)

    # print(dir(mw))
    #print('executing app')
    #mw.setupProgress()
    #mw.progress.start(immediate=True)
    #mw.progress._lastUpdate = time.time()
    #mw.onSync()
    app.exec_()
예제 #2
0
class Anki:
    """My Anki collection wrapper class."""

    def __init__(self, base=None, path=None, profile=None, **_kwargs):
        self.modified = False

        self._init_load_collection(base, path, profile)
        self._init_load_config()

        self.model_name_to_id = {m['name']: m['id']
                                 for m in self.col.models.all()}
        self.model_names = self.model_name_to_id.keys()

        self.deck_name_to_id = {d['name']: d['id']
                                for d in self.col.decks.all()}
        self.deck_names = self.deck_name_to_id.keys()
        self.n_decks = len(self.deck_names)

    def _init_load_collection(self, base, path, profile):
        """Load the Anki collection"""
        # Save CWD (because Anki changes it)
        save_cwd = os.getcwd()

        if path is None:
            if base is None:
                click.echo('Base path is not properly set!')
                raise click.Abort()

            basepath = Path(base)
            if not (basepath / 'prefs21.db').exists():
                click.echo('Invalid base path!')
                click.echo(f'path = {basepath.absolute()}')
                raise click.Abort()

            # Initialize a profile manager to get an interface to the profile
            # settings and main database path; also required for syncing
            self.pm = ProfileManager(base)
            self.pm.setupMeta()

            if profile is None:
                profile = self.pm.profiles()[0]

            # Load the main Anki database/collection
            self.pm.load(profile)
            path = self.pm.collectionPath()
        else:
            self.pm = None

        try:
            self.col = anki.Collection(path)
        except AssertionError:
            click.echo('Path to database is not valid!')
            click.echo(f'path = {path}')
            raise click.Abort()
        except anki.errors.DBError:
            click.echo('Database is NA/locked!')
            raise click.Abort()

        # Restore CWD (because Anki changes it)
        os.chdir(save_cwd)

    @staticmethod
    def _init_load_config():
        """Load custom configuration"""
        # Update LaTeX commands
        # * Idea based on Anki addon #1546037973 ("Edit LaTeX build process")
        if 'pngCommands' in cfg:
            anki.latex.pngCommands = cfg['pngCommands']
        if 'svgCommands' in cfg:
            anki.latex.svgCommands = cfg['svgCommands']


    def __enter__(self):
        return self

    def __exit__(self, exception_type, exception_value, traceback):
        if self.modified:
            click.echo('Database was modified.')
            if self.pm is not None and self.pm.profile['syncKey']:
                click.secho('Remember to sync!', fg='blue')
            self.col.close()
        elif self.col.db:
            self.col.close(False)


    def sync(self):
        """Sync collection to AnkiWeb"""
        if self.pm is None:
            return

        auth = self.pm.sync_auth()
        if auth is None:
            return

        # Make sure database is saved first
        self.col.save(trx=False)

        # Perform main sync
        try:
            debug_output = 'anki::sync=debug' in os.environ.get('RUST_LOG', '')

            if debug_output:
                click.secho('Syncing deck:', fg='blue')
            else:
                click.echo('Syncing deck ... ', nl=False)

            self.col.sync_collection(auth)

            if not debug_output:
                click.echo('done!')
            else:
                click.echo('')
        except Exception as e:
            click.secho('Error during sync!', fg='red')
            click.echo(e)
            raise click.Abort()

        # Perform media sync
        try:
            debug_output = 'media=debug' in os.environ.get('RUST_LOG', '')

            with cd(self.col.media.dir()):
                if debug_output:
                    click.secho('Syncing media:', fg='blue')
                else:
                    click.echo('Syncing media ... ', nl=False)
                self.col.sync_media(auth)
                if not debug_output:
                    click.echo('done!')
        except Exception as e:
            if "sync cancelled" in str(e):
                return
            raise


    def check_media(self):
        """Check media (will rebuild missing LaTeX files)"""
        with cd(self.col.media.dir()):
            click.echo('Checking media DB ... ', nl=False)
            output = self.col.media.check()
            click.echo('done!')

            if len(output.missing) + len(output.unused) == 0:
                click.secho('No unused or missing files found.', fg='white')
                return

            for file in output.missing:
                click.secho(f'Missing: {file}', fg='red')

            if len(output.missing) > 0 \
                    and click.confirm('Render missing LaTeX?'):
                out = self.col.media.render_all_latex()
                if out is not None:
                    nid, _ = out
                    click.secho(f'Error prosessing node: {nid}', fg='red')

                    if click.confirm('Review note?'):
                        note = Note(self, self.col.getNote(nid))
                        note.review()

            for file in output.unused:
                click.secho(f'Unused: {file}', fg='red')

            if len(output.unused) > 0 \
                    and click.confirm('Delete unused media?'):
                for file in output.unused:
                    if os.path.isfile(file):
                        os.remove(file)


    def find_cards(self, query):
        """Find card ids in Collection that match query"""
        return self.col.findCards(query)

    def find_notes(self, query):
        """Find notes in Collection and return Note objects"""
        return (Note(self, self.col.getNote(i))
                for i in set(self.col.findNotes(query)))

    def delete_notes(self, ids):
        """Delete notes by note ids"""
        if not isinstance(ids, list):
            ids = [ids]

        self.col.remNotes(ids)
        self.modified = True


    def get_model(self, model_name):
        """Get model from model name"""
        return self.col.models.get(self.model_name_to_id.get(model_name))

    def set_model(self, model_name):
        """Set current model based on model name"""
        current = self.col.models.current(forDeck=False)
        if current['name'] == model_name:
            return current

        model = self.get_model(model_name)
        if model is None:
            click.secho(f'Model "{model_name}" was not recognized!')
            raise click.Abort()

        self.col.models.setCurrent(model)
        return model

    def rename_model(self, old_model_name, new_model_name):
        """Rename a model"""
        if old_model_name not in self.model_names:
            click.echo('Can''t rename model!')
            click.echo(f'No such model: {old_model_name}')
            raise click.Abort()

        # Change the name
        model = self.get_model(old_model_name)
        model['name'] = new_model_name

        # Update local storage
        self.model_name_to_id = {m['name']: m['id']
                                 for m in self.col.models.all()}
        self.model_names = self.model_name_to_id.keys()

        # Save changes
        self.col.models.save(model)
        self.modified = True


    def list_tags(self):
        """List all tags"""
        tags = [(t, len(self.col.findNotes(f'tag:{t}')))
                for t in self.col.tags.all()]
        width = len(max(tags, key=lambda x: len(x[0]))[0]) + 2
        filler = " "*(cfg['width'] - 2*width - 8)

        for (t1, n1), (t2, n2) in zip(
                sorted(tags, key=lambda x: x[0]),
                sorted(tags, key=lambda x: x[1])):
            click.echo(f'{t1:{width}s}{n1:4d}{filler}{t2:{width}s}{n2:4d}')

    def change_tags(self, query, tags, add=True):
        """Add/Remove tags from notes that match query"""
        self.col.tags.bulkAdd(self.col.findNotes(query), tags, add)
        self.modified = True


    def edit_model_css(self, model_name):
        """Edit the CSS part of a given model."""
        model = self.get_model(model_name)

        with tempfile.NamedTemporaryFile(mode='w+', prefix='_apy_edit_',
                                         suffix='.css', delete=False) as tf:
            tf.write(model['css'])
            tf.flush()

            retcode = editor(tf.name)
            if retcode != 0:
                click.echo(f'Editor return with exit code {retcode}!')
                return

            with open(tf.name, 'r') as f:
                new_content = f.read()

        if model['css'] != new_content:
            model['css'] = new_content
            self.col.models.save(model, templates=True)
            self.modified = True


    def list_notes(self, query, verbose=False):
        """List notes that match a query"""
        for note in self.find_notes(query):
            first_field = html_to_screen(note.n.values()[0])
            first_field = first_field.replace('\n', ' ')
            first_field = re.sub(r'\s\s\s+', ' ', first_field)
            first_field = first_field[:cfg['width']-14] \
                + click.style('', reset=True)

            first = 'Q: '
            if note.suspended:
                first = click.style(first, fg='red')
            elif 'marked' in note.n.tags:
                first = click.style(first, fg='yellow')

            click.echo(f'{first}{first_field}')
            if verbose:
                click.echo(f'model: {note.model_name}\n')

    def list_cards(self, query, verbose=False):
        """List cards that match a query"""
        for cid in self.find_cards(query):
            c = self.col.getCard(cid)
            question = BeautifulSoup(html_to_screen(c.q()),
                                     features='html5lib')
            question = re.sub(r'\s\s+', ' ',
                              question.get_text().replace('\n', ' ').strip())
            answer = BeautifulSoup(html_to_screen(c.a()),
                                     features='html5lib')
            answer = re.sub(r'\s\s+', ' ',
                              answer.get_text().replace('\n', ' ').strip())

            card_type = ['new', 'learning', 'review', 'relearning'][c.type]

            click.echo(f"{click.style('', fg='reset')}"
                       f"{click.style('Q:', fg='yellow')} "
                       f"{question[:cfg['width']]}")
            if verbose:
                click.echo(f"{click.style('', fg='reset')}"
                           f"{click.style('A:', fg='yellow')} "
                           f"{answer[:cfg['width']]}")

                click.echo(
                    f"{click.style('', fg='reset')}"
                    f"{click.style('cid:', fg='yellow')} {cid} "
                    f"{click.style('type:', fg='yellow')} {card_type} "
                    f"{click.style('ease:', fg='yellow')} {c.factor/10}% "
                    f"{click.style('lapses:', fg='yellow')} {c.lapses} "
                    f"{click.style('due:', fg='yellow')} {c.due} "
                    f"{click.style('model:', fg='yellow')} {c.model()['name']}\n")


    def add_notes_with_editor(self, tags='', model_name=None, deck_name=None,
                              template=None):
        """Add new notes to collection with editor"""
        if isinstance(template, Note):
            input_string = template.get_template()
        else:
            if model_name is None or model_name.lower() == 'ask':
                model_name = choose(sorted(self.model_names), "Choose model:")

            model = self.set_model(model_name)

            if deck_name is None:
                deck_name = self.col.decks.current()['name']
            elif deck_name.lower() == 'ask':
                deck_name = choose(sorted(self.deck_names), "Choose deck:")

            input_string = [f'model: {model_name}']

            if self.n_decks > 1:
                input_string += [f'deck: {deck_name}']

            input_string += [f'tags: {tags}']

            if model_name not in cfg['markdown_models']:
                input_string += ['markdown: false']

            input_string += ['\n# Note\n']

            input_string += [x for y in
                             [[f'## {field["name"]}', '']
                              for field in model['flds']]
                             for x in y]

            input_string = '\n'.join(input_string) + '\n'

        with tempfile.NamedTemporaryFile(mode='w+',
                                         prefix='apy_note_',
                                         suffix='.md',
                                         delete=False) as tf:
            tf.write(input_string)
            tf.flush()
            retcode = editor(tf.name)

            if retcode != 0:
                click.echo(f'Editor return with exit code {retcode}!')
                return []

            return self.add_notes_from_file(tf.name)

    def add_notes_from_file(self, filename, tags=''):
        """Add new notes to collection from Markdown file"""
        return self.add_notes_from_list(markdown_file_to_notes(filename),
                                        tags)

    def add_notes_from_list(self, parsed_notes, tags=''):
        """Add new notes to collection from note list (from parsed file)"""
        notes = []
        for note in parsed_notes:
            model_name = note['model']
            model = self.set_model(model_name)
            model_field_names = [field['name'] for field in model['flds']]

            field_names = note['fields'].keys()
            field_values = note['fields'].values()

            if len(field_names) != len(model_field_names):
                click.echo(f'Error: Not enough fields for model {model_name}!')
                self.modified = False
                raise click.Abort()

            for x, y in zip(model_field_names, field_names):
                if x != y:
                    click.echo('Warning: Inconsistent field names '
                               f'({x} != {y})')

            notes.append(self._add_note(field_values,
                                        f"{tags} {note['tags']}",
                                        note['markdown'],
                                        note.get('deck')))

        return notes

    def add_notes_single(self, fields, tags='', model=None, deck=None):
        """Add new note to collection from args"""
        if model is not None:
            self.set_model(model)

        self._add_note(fields, tags, False, deck)

    def _add_note(self, fields, tags, markdown=True, deck=None):
        """Add new note to collection"""
        note = self.col.newNote(forDeck=False)

        if deck is not None:
            note.model()['did'] = self.deck_name_to_id[deck]

        if markdown:
            note.fields = [markdown_to_html(x) for x in fields]
        else:
            note.fields = [plain_to_html(x) for x in fields]

        tags = tags.strip().split()
        for tag in tags:
            note.addTag(tag)

        if not note.dupeOrEmpty():
            self.col.addNote(note)
            self.modified = True
        else:
            click.secho('Dupe detected, note was not added!', fg='red')
            click.echo('Question:')
            click.echo(list(fields)[0])

        return Note(self, note)
예제 #3
0
class Anki:
    """My Anki collection wrapper class."""
    def __init__(self, base=None, path=None, debug=False):
        self.modified = False
        self._debug = debug

        self._init_load_collection(base, path)
        self._init_load_config()

        self.model_name_to_id = {
            m['name']: m['id']
            for m in self.col.models.all()
        }
        self.model_names = self.model_name_to_id.keys()

        self.deck_name_to_id = {
            d['name']: d['id']
            for d in self.col.decks.all()
        }
        self.deck_names = self.deck_name_to_id.keys()
        self.n_decks = len(self.deck_names)

    def _init_load_collection(self, base, path):
        """Load the Anki collection"""
        from pathlib import Path
        from sqlite3 import OperationalError

        import anki
        from aqt.profiles import ProfileManager

        import click

        # Save CWD (because Anki changes it)
        save_cwd = os.getcwd()

        if path is None:
            basepath = Path(base)
            if not (basepath / 'prefs21.db').exists():
                click.echo('Invalid base path!')
                click.echo(f'path = {basepath.absolute()}')
                raise click.Abort()

            # Initialize a profile manager to get an interface to the profile
            # settings and main database path; also required for syncing
            self.pm = ProfileManager(base)
            self.pm.setupMeta()
            self.pm.load(self.pm.profiles()[0])

            # Load the main Anki database/collection
            path = self.pm.collectionPath()
        else:
            self.pm = None

        try:
            self.col = anki.Collection(path)
        except AssertionError:
            click.echo('Path to database is not valid!')
            click.echo(f'path = {path}')
            raise click.Abort()
        except OperationalError:
            click.echo('Database is NA/locked!')
            raise click.Abort()

        # Restore CWD (because Anki changes it)
        os.chdir(save_cwd)

    def _init_load_config(self):
        """Load custom configuration"""
        import anki
        from apy.config import cfg

        # Update LaTeX commands
        # * Idea based on Anki addon #1546037973 ("Edit LaTeX build process")
        if 'pngCommands' in cfg:
            anki.latex.pngCommands = cfg['pngCommands']
        if 'svgCommands' in cfg:
            anki.latex.svgCommands = cfg['svgCommands']

    def __enter__(self):
        return self

    def __exit__(self, exception_type, exception_value, traceback):
        import click

        if self.modified and not self._debug:
            click.echo('Database was modified.')
            if self.pm is not None and self.pm.profile['syncKey']:
                click.secho('Remember to sync!', fg='blue')
            self.col.close()

    def sync(self):
        """Sync collection to AnkiWeb"""
        if self.pm is None:
            return

        import click

        if not self.pm.profile['syncKey']:
            click.echo('No sync auth registered in profile')
            return

        from anki.sync import (Syncer, MediaSyncer, RemoteServer,
                               RemoteMediaServer)

        # Initialize servers and sync clients
        hkey = self.pm.profile['syncKey']
        hostNum = self.pm.profile.get('hostNum')
        server = RemoteServer(hkey, hostNum=hostNum)
        main_client = Syncer(self.col, server)
        media_client = MediaSyncer(
            self.col,
            RemoteMediaServer(self.col, hkey, server.client, hostNum=hostNum))

        # Perform main sync
        try:
            click.echo('Syncing deck ... ', nl=False)
            ret = main_client.sync()
        except Exception as e:
            if 'sync cancelled' in str(e):
                server.abort()
            click.secho('Error during sync!', fg='red')
            click.echo(e)
            raise click.Abort()

        # Parse return value
        if ret == "noChanges":
            click.echo('done (no changes)!')
        elif ret == "success":
            click.echo('done!')
        elif ret == "serverAbort":
            click.echo('aborted!')
            return
        elif ret == "fullSync":
            click.echo('aborted!')
            click.secho('Full sync required!', fg='red')
            return
        else:
            click.echo('failed!')
            click.echo(f'Message: {ret}')
            return

        # Perform media sync
        try:
            click.echo('Syncing media ... ', nl=False)
            save_cwd = os.getcwd()
            os.chdir(self.col.media.dir())
            ret = media_client.sync()
            os.chdir(save_cwd)
        except Exception as e:
            if "sync cancelled" in str(e):
                return
            raise

        if ret == "noChanges":
            click.echo('done (no changes)!')
        elif ret in ("sanityCheckFailed", "corruptMediaDB"):
            click.echo('failed!')
        else:
            click.echo('done!')

    def check_media(self):
        """Check media (will rebuild missing LaTeX files)"""
        import click
        from apy.utilities import cd

        with cd(self.col.media.dir()):
            click.echo('Checking media DB ... ', nl=False)
            nohave, unused, warnings = self.col.media.check()
            click.echo('done!')

            if not warnings + unused + nohave:
                click.secho('No unused or missing files found.', fg='white')
                return

            for warning in warnings:
                click.secho(warning, fg='red')

            for file in nohave:
                click.secho(f'Missing: {file}', fg='red')

            if unused:
                for file in unused:
                    click.secho(f'Unused: {file}', fg='red')

                if not click.confirm('Delete unused media?'):
                    return

                for file in unused:
                    if os.path.isfile(file):
                        os.remove(file)

    def find_cards(self, query):
        """Find card ids in Collection that match query"""
        return self.col.findCards(query)

    def find_notes(self, query):
        """Find notes in Collection and return Note objects"""
        from apy.note import Note
        return (Note(self, self.col.getNote(i))
                for i in self.col.findNotes(query))

    def delete_notes(self, ids):
        """Delete notes by note ids"""
        if not isinstance(ids, list):
            ids = [ids]

        self.col.remNotes(ids)
        self.modified = True

    def get_model(self, model_name):
        """Get model from model name"""
        return self.col.models.get(self.model_name_to_id.get(model_name))

    def set_model(self, model_name):
        """Set current model based on model name"""
        import click

        current = self.col.models.current(forDeck=False)
        if current['name'] == model_name:
            return current

        model = self.get_model(model_name)
        if model is None:
            click.secho(f'Model "{model_name}" was not recognized!')
            raise click.Abort()

        self.col.models.setCurrent(model)
        return model

    def edit_model_css(self, model_name):
        """Edit the CSS part of a given model."""
        import tempfile
        import click
        from apy.utilities import editor

        model = self.get_model(model_name)

        with tempfile.NamedTemporaryFile(mode='w+',
                                         prefix='_apy_edit_',
                                         suffix='.css',
                                         delete=False) as tf:
            tf.write(model['css'])
            tf.flush()

            retcode = editor(tf.name)
            if retcode != 0:
                click.echo(f'Editor return with exit code {retcode}!')
                return

            with open(tf.name, 'r') as f:
                new_content = f.read()

        if model['css'] != new_content:
            model['css'] = new_content
            self.col.models.save(model, templates=True)
            self.modified = True

    def add_notes_with_editor(self,
                              tags='',
                              model_name=None,
                              deck_name=None,
                              template=None):
        """Add new notes to collection with editor"""
        import tempfile

        import click

        from apy.utilities import editor, choose
        from apy.note import Note

        if isinstance(template, Note):
            input_string = template.get_template()
        else:
            if model_name is None or model_name.lower() == 'ask':
                model_name = choose(sorted(self.model_names), "Choose model:")

            model = self.set_model(model_name)

            if deck_name is None:
                deck_name = self.col.decks.current()['name']
            elif deck_name.lower() == 'ask':
                deck_name = choose(sorted(self.deck_names), "Choose deck:")

            input_string = [f'model: {model_name}']

            if self.n_decks > 1:
                input_string += [f'deck: {deck_name}']

            input_string += [f'tags: {tags}']

            if model_name != 'Basic':
                input_string += ['markdown: false']

            input_string += ['\n# Note\n']

            input_string += [
                x for y in [[f'## {field["name"]}', '']
                            for field in model['flds']] for x in y
            ]

            input_string = '\n'.join(input_string) + '\n'

        with tempfile.NamedTemporaryFile(mode='w+',
                                         dir=os.getcwd(),
                                         prefix='note_',
                                         suffix='.md',
                                         delete=False) as tf:
            tf.write(input_string)
            tf.flush()
            retcode = editor(tf.name)

            if retcode != 0:
                click.echo(f'Editor return with exit code {retcode}!')
                return []

            return self.add_notes_from_file(tf.name)

    def add_notes_from_file(self, filename, tags=''):
        """Add new notes to collection from Markdown file"""
        from apy.convert import markdown_file_to_notes
        return self.add_notes_from_list(markdown_file_to_notes(filename), tags)

    def add_notes_from_list(self, parsed_notes, tags=''):
        """Add new notes to collection from note list (from parsed file)"""
        import click

        notes = []
        for note in parsed_notes:
            model_name = note['model']
            model = self.set_model(model_name)
            model_field_names = [field['name'] for field in model['flds']]

            field_names = note['fields'].keys()
            field_values = note['fields'].values()

            if len(field_names) != len(model_field_names):
                click.echo(f'Error: Not enough fields for model {model_name}!')
                self.modified = False
                raise click.Abort()

            for x, y in zip(model_field_names, field_names):
                if x != y:
                    click.echo('Warning: Inconsistent field names '
                               f'({x} != {y})')

            notes.append(
                self._add_note(field_values, f"{tags} {note['tags']}",
                               note['markdown'], note.get('deck')))

        return notes

    def _add_note(self, fields, tags, markdown=True, deck=None):
        """Add new note to collection"""
        import click

        from apy.convert import markdown_to_html, plain_to_html
        from apy.note import Note

        note = self.col.newNote(forDeck=False)

        if deck is not None:
            note.model()['did'] = self.deck_name_to_id[deck]

        if markdown:
            note.fields = [markdown_to_html(x) for x in fields]
        else:
            note.fields = [plain_to_html(x) for x in fields]

        tags = tags.strip().split()
        for tag in tags:
            note.addTag(tag)

        if not note.dupeOrEmpty():
            self.col.addNote(note)
            self.modified = True
        else:
            click.secho('Dupe detected, note was not added!', fg='red')
            click.echo('Question:')
            click.echo(list(fields)[0])

        return Note(self, note)