Beispiel #1
0
 def test_note_from_file_with_bad_notebook_format(self):
     path_to_test_note = os.path.join(THIS_DIR, 'data/note-with_bad_nb_format.md')
     note = Note()
     with self.assertRaises(RuntimeError) as cm:
         note.from_file(path_to_test_note)
     the_exception = cm.exception
     self.assertEqual(the_exception.message, 'Bad notebook line format')
Beispiel #2
0
def new(title, notebook_name="personal", body=""):
    """
    Create new note in notebook
    :param title:
    :param notebook_name: optional
    :param body: optional
    :return: new_note.id
    """

    # Ensure that note with the same title does not exist already in the database
    try:
        note = Note.get(Note.title == title)
        notification.show_error("Note with same name already exists", note.title)
        raise Exception(f"Note with same name already exists")
    except Note.DoesNotExist:
        # All good, it should not exist
        pass

    # Retrieve notebook id
    try:
        notebook = Folder.get(Folder.title == notebook_name)
    except Folder.DoesNotExist:
        notification.show_error("Notebook not found", notebook_name)
        raise Folder.DoesNotExist

    # Create new note instance
    # Get unique identifier following Joplin format
    # Source: https://github.com/laurent22/joplin/issues/305#issuecomment-374123414
    import uuid

    uid = str(uuid.uuid4()).replace("-", "")
    # Get current time in Joplin format (int epoch in milliseconds)
    uint_current_timestamp_msec = time_joplin()
    new_note = Note(
        body=body,
        created_time=uint_current_timestamp_msec,
        id=uid,
        parent=notebook.id,
        source="pyjoplin",
        source_application="pyjoplin",
        title=title,
        updated_time=uint_current_timestamp_msec,
        user_created_time=uint_current_timestamp_msec,
        user_updated_time=uint_current_timestamp_msec,
    )
    num_saved_notes = new_note.save(force_insert=True)
    if num_saved_notes != 1:
        notification.show_error_and_raise("Creating note", new_note.title)
    if config.DO_NOTIFY:
        notification.show("New note created in nb %s" % notebook.title, title)

    return new_note.id
Beispiel #3
0
def imfeelinglucky(uid):
    """
    Try to make most straightforward action on note,
    similar in spirit to "I'm feeling lucky" feature in Google search.
    :param uid:
    :return:
    """
    subprocess.Popen(
        "increase_hit_history_for pyjoplin %s" % inspect.currentframe().f_code.co_name,
        shell=True,
    )

    # NOTE: For now only implements searching first code stub in Solution:
    # In other cases it could open url for "link-type" notes

    # Find note entry by uid
    note = Note.get(Note.id == uid)

    # Parse first code stub (if any)
    import re

    stub = ""
    if not stub:
        # Typical example:
        # # Solution...
        # ... (one or several lines)
        # ```<lang>
        # <code_stub>
        # ```
        pattern_str = r"#?\s*Solution.*?```.*?\n(.*?)```"
        m = re.search(pattern_str, note.body, re.DOTALL)
        if m:
            stub = m.group(1)
    if not stub:
        # Inline solution:
        # # Solution...
        # ... (one or several lines)
        # `<code_stub>`
        pattern_str = r"#?\s*Solution.*?`(.*?)`"
        m = re.search(pattern_str, note.body, re.DOTALL)
        if m:
            stub = m.group(1)
    if not stub:
        notification.show("No code stub found, opening file", note.title)
        edit(uid)
        return None
    # Strip newlines to avoid unexpected execution e.g. in shell
    stub = stub.strip("\n")

    # Copy stub into the clipboard
    # NOTE: Using xclip because pyperclip does not work
    # Create temporary file with text
    path_tempfile = os.path.join(config.PATH_TEMP, "stub")
    with open(path_tempfile, "w") as f:
        f.write(stub)
    cmd = "xclip -selection clipboard %s" % path_tempfile
    subprocess.call(cmd, shell=True)

    notification.show("Extracted code stub:", note.title, stub)
    return stub
Beispiel #4
0
def rebuild_fts_index():
    """
    Rebuild virtual table for FTS (fulltext search)
    populating with all existing notes

    :return:
    """
    # Create empty FTS table from scratch
    try:
        # Remove table in case it existed
        NoteIndex.drop_table()
    except Exception:
        pass
    NoteIndex.create_table()

    # Add all entries into virtual FTS table
    notes = Note.select()
    with db.atomic():
        for note in notes:
            NoteIndex.insert(
                {
                    NoteIndex.uid: note.id,
                    NoteIndex.title: note.title,
                    NoteIndex.body: note.body,
                }
            ).execute()

    if config.DO_NOTIFY:
        notification.show("Rebuilt index", message="FTS index populated from scratch")
Beispiel #5
0
def rename_conflicting_notes():
    notes = Note.select().where(Note.is_conflict == 1)
    with db.atomic():
        for note in notes:
            if not note.title.endswith("(CONFLICT)"):
                note.title = note.title + " (CONFLICT)"
                num_saved_notes = note.save()
                if num_saved_notes != 1:
                    notification.show_error("Renaming conflicting note", note.title)
Beispiel #6
0
def edit_by_title(title):
    if isinstance(title, list):
        # For case coming from CLI
        title = " ".join(title)
    try:
        note = Note.get(Note.title == title.rstrip())
        edit(note.id)
    except Note.DoesNotExist:
        print("No exact match found for title query " + title)
Beispiel #7
0
def list_conflicts():
    """
    List conflicting notes

    :return:
    """

    notes = Note.select().where(Note.is_conflict == 1)
    print("List of conflicting notes:")
    with db.atomic():
        for note in notes:
            print("-----------------------------")
            print("%s %s" % (note.id, note.title))
Beispiel #8
0
def print_for_uid(uid):
    """
    Print note content
    :param uid:
    :return: returncode
    """
    try:
        note = Note.get(Note.id == uid)
        print(note.to_string(), end="")
        return 0
    except Note.DoesNotExist:
        print(f"No note found with id {uid}")
        return 2
Beispiel #9
0
    def test_note_to_file_with_unicode(self):
        test_title = 'pyjoplin_test test_note_to_file_with_unicode'
        test_body = u"This is a unicode string: \u2192"

        note_id = commands.new(test_title, 'test', body=test_body)
        new_note = Note.get(Note.id == note_id)

        path_tempfile = '/tmp/pyjoplin/test_note_to_file_with_unicode'
        new_note.to_file(path_tempfile)

        backup_body = new_note.body
        new_note.from_file(path_tempfile)

        self.assertEqual(new_note.body, backup_body)
Beispiel #10
0
def get_notes_by_id(ids, ordered=False):
    # Find notes with id in uids
    # Peewee: `x << y` stands for `x in y`
    # NOTE: Order in query is unrelated to order in uids
    query = Note.select().where(Note.id << ids).dicts()
    # Debug: [note['id'] for note in query]

    if ordered:
        # Order query list in same order as uids
        dict_query = {note["id"]: note for note in query}
        # Grab note only if in the query
        ordered_notes = [dict_query[id] for id in ids if id in dict_query]
        query = ordered_notes
        # Debug: [note['id'] for note in query]

    return query
Beispiel #11
0
def find_empty_notes(delete=False):
    """
    Find and report empty notes
    Useful e.g. to find if some note was left empty due to synchronization issues

    :return:
    """

    notes = Note.select().order_by(Note.title)
    print("Listing empty notes:")
    with db.atomic():
        for note in notes:
            if not note.body:
                print("%s %s" % (note.id, note.title))
                if delete:
                    note.delete_instance()
                    print("Deleted")
Beispiel #12
0
def delete(uid):
    # Find note entry by uid
    note = Note.get(Note.id == uid)
    note.delete_instance()
    if config.DO_NOTIFY:
        notification.show("Deleted note", note.title)
Beispiel #13
0
def find_title(title):
    try:
        note = Note.get(Note.title == title)
        return note.id
    except Note.DoesNotExist:
        return None
Beispiel #14
0
 def test_note_from_file(self):
     path_to_test_note = os.path.join(THIS_DIR, 'data/note.md')
     note = Note()
     note.from_file(path_to_test_note)
Beispiel #15
0
def edit(uid):
    subprocess.Popen(
        "increase_hit_history_for pyjoplin %s" % inspect.currentframe().f_code.co_name,
        shell=True,
    )

    # Find note entry by uid
    note = Note.get(Note.id == uid)
    # Save previous title and body for reference
    init_title = note.title
    init_body = note.body

    # Populate temporary file from note content
    path_sentinel = os.path.join(config.PATH_TEMP, f"{uid}")
    if os.path.exists(path_sentinel):
        notification.show_error("Note is already under edit", note.title, note.id)
        raise Exception("Note is already under edit")
    else:
        # Create sentinel to block this note
        open(path_sentinel, "a").close()

    path_tempfile = os.path.join(config.PATH_TEMP, f"{note.title.replace('/', '_')}.md")

    # Create named symlink to sentinel note
    # for better readability
    # NOTE:
    #   Because of this link, the sentinel is no longer *just* a sentinel.
    #   Instead, it is a plain text file containing the actual note content.
    try:
        os.symlink(path_sentinel, path_tempfile)
    except FileExistsError:
        notification.show_error(
            "Note file with same name is already under edit", note.title, note.id
        )
        raise Exception(f"Temp file named {note.title} already exists")

    note.to_file(path_tempfile)

    # Open file with editor
    # NOTE: Stop using template, this command gets too complicated for a single string
    #       It's better to have a list of inputs to build the last complicated command
    #       Nesting bash becomes necessary to execute source for non-interactive customization
    # NOTE: Using vimx since aliases do not seem to apply in non-interactive (even if I define it inside bashrc!?)
    proc = subprocess.Popen(
        # [
        #     'xfce4-terminal',
        #     '--disable-server',
        #     '--title',
        #     'pyjoplin - {title}'.format(title=note.title),
        #     '-e',
        #     'bash -c "source ~/.bashrc && unset PYTHONPATH && vimx \'{path}\' || vi \'{path}\'"'.format(path=path_tempfile)
        # ],
        [
            "run-standalone.sh",
            "bash",
            "-c",
            # NOTE: `stty -ixon` to disable "flow control characters" for vim-shell
            # NOTE: `unset PYTHONPATH` seems necessary for this to work when called through ulauncher extension
            f"stty -ixon && unset PYTHONPATH && vim -u ~/Code/python/pyjoplin/vim/vimrc '{path_tempfile}'"
            # NOTE: Version below works when called from terminal,
            # e.g. `pyjoplin edit 170b3c8de5034f7c8023a6a39f02219c`
            # but it immediately exits when called via ulauncher
            # 'vimx',
            # '{path}'.format(path=path_tempfile)
        ],
        env=dict(os.environ, OPT_TITLE=f"pyjoplin - {note.title}"),
        stdout=subprocess.PIPE,
    )
    # cmd_str = config.EDITOR_CALL_TEMPLATE.format(title=note.title, path=path_tempfile)
    # proc = subprocess.Popen(
    #     cmd_str, shell=True,
    #     # NOTE: This is needed for non-gedit editors, but DISPLAY seems to be giving issues
    #     # env=dict(
    #     #     os.environ,
    #     #     PYTHONPATH='',  # avoid issues when calling Python3 scripts from Python2
    #     #     DISPLAY=":0.0"  # ensure some GUI apps catch the right display
    #     # ),
    #     stdout=subprocess.PIPE
    # )

    # Loop during edition to save incremental changes
    import time

    last_modification_time_sec = os.path.getmtime(path_tempfile)
    try:
        # Sync note under edition with database note in a loop
        while proc.poll() is None:
            time.sleep(0.5)
            if os.path.getmtime(path_tempfile) > last_modification_time_sec:
                last_modification_time_sec = os.path.getmtime(path_tempfile)

                note.from_file(path_tempfile)
                num_saved_notes = note.save()
                if num_saved_notes != 1:
                    notification.show_error(
                        "Saving note changes during edition", note.title
                    )
                # else:
                #     notification.show("Note saved", note.title)
    except:
        notification.show_error("During note edition sync loop", note.title)
        raise Exception("ERROR during editor-database sync loop")

    returncode = proc.wait()
    if returncode != 0:
        print("ERROR during edition")
        print("Output:")
        output = proc.stdout.read().decode("utf-8")
        print(output)
        notification.show_error(
            "returncode from editor was not 0. Inspect note to sanity-check.",
            note.title,
            output,
        )
        raise Exception("ERROR during edition")

    # Save edited file content to Notes table
    note.from_file(path_tempfile)

    # Cleanup temp files
    # NOTE:
    # If the editor finished with some error code
    # and things didn't go well,
    # we do NOT remove the sentinel.
    # So we will get an error when trying to open the note again, showcasing the issue
    # (even though you should have got an error notification, but just in case!)
    os.remove(path_tempfile)
    os.remove(path_sentinel)

    # Check for note changes
    if note.title == init_title and note.body == init_body:
        # Changed nothing, no need to save
        if config.DO_NOTIFY:
            notification.show("Finished edition with no changes", note.title)
        return

    # Delete entry if empty
    if (not note.title) and (not note.body):
        note.delete_instance()
        if config.DO_NOTIFY:
            notification.show("Deleted note", init_title)
        return

    # Save note changes into database
    # NOTE: FTS entry is automatically updated within .save()
    num_saved_notes = note.save()
    if num_saved_notes != 1:
        notification.show_error_and_raise("Saving note changes", note.title)

    if config.DO_NOTIFY:
        notification.show("Note saved", note.title)
Beispiel #16
0
 def test_note_from_file_with_bad_notebook_name(self):
     path_to_test_note = os.path.join(THIS_DIR, 'data/note-with_bad_nb_name.md')
     note = Note()
     with self.assertRaises(Folder.DoesNotExist) as cm:
         note.from_file(path_to_test_note)
Beispiel #17
0
def main():
    """Main program.
    Parse command line, then iterate over files and directories under
    rootdir and upload all files.  Skips some temporary files and
    directories, and avoids duplicate uploads by comparing size and
    mtime with the server.
    """
    # Get list of notes in Facebook notebook
    # These will be skipped in synchronization
    notebook = Folder.get(Folder.title == "fb")
    fb_notebook_id = notebook.id
    # NOTE: If fb folder does not exist, will throw FolderDoesNotExist
    # TODO: Catch it
    query = Note.select().where(Note.parent == fb_notebook_id)
    with db.atomic():
        fb_note_ids = [note.id for note in query]

    args = parser.parse_args()
    if sum([bool(b) for b in (args.yes, args.no, args.default)]) > 1:
        print("At most one of --yes, --no, --default is allowed")
        sys.exit(2)

    folder = args.folder
    rootdir = os.path.expanduser(args.rootdir)
    print("Dropbox folder name:", folder)
    print("Local directory:", rootdir)
    if not os.path.exists(rootdir):
        print(rootdir, "does not exist on your filesystem")
        sys.exit(1)
    elif not os.path.isdir(rootdir):
        print(rootdir, "is not a folder on your filesystem")
        sys.exit(1)

    for dn, dirs, files in os.walk(rootdir):
        subfolder = dn[len(rootdir):].strip(os.path.sep)
        listing = list_folder(dbx, folder, subfolder)
        print("Descending into", subfolder, "...")

        # First do all the files.
        for name in files:
            fullname = os.path.join(dn, name)
            if not isinstance(name, six.text_type):
                name = name.decode("utf-8")
            nname = unicodedata.normalize("NFC", name)
            if name.startswith("."):
                print("Skipping dot file:", name)
            elif name.startswith("@") or name.endswith("~"):
                print("Skipping temporary file:", name)
            elif name.endswith(".pyc") or name.endswith(".pyo"):
                print("Skipping generated file:", name)
            elif nname in listing:
                md = listing[nname]
                mtime = os.path.getmtime(fullname)
                mtime_dt = datetime.datetime(*time.gmtime(mtime)[:6])
                size = os.path.getsize(fullname)
                if (isinstance(md, dropbox.files.FileMetadata)
                        and mtime_dt == md.client_modified
                        and size == md.size):
                    print(name, "is already synced [stats match]")
                else:
                    print(name, "exists with different stats, downloading")
                    res = download(dbx, folder, subfolder, name)
                    with open(fullname) as f:
                        data = f.read()
                    if res == data:
                        print(name, "is already synced [content match]")
                    else:
                        print(name, "has changed since last sync")
                        if yesno("Refresh %s" % name, False, args):
                            upload(dbx,
                                   fullname,
                                   folder,
                                   subfolder,
                                   name,
                                   overwrite=True)
            elif yesno("Upload %s" % name, True, args):
                upload(dbx, fullname, folder, subfolder, name)

        # Then choose which subdirectories to traverse.
        keep = []
        for name in dirs:
            if name.startswith("."):
                print("Skipping dot directory:", name)
            elif name.startswith("@") or name.endswith("~"):
                print("Skipping temporary directory:", name)
            elif name == "__pycache__":
                print("Skipping generated directory:", name)
            elif yesno("Descend into %s" % name, True, args):
                print("Keeping directory:", name)
                keep.append(name)
            else:
                print("OK, skipping directory:", name)
        dirs[:] = keep
from __future__ import print_function
from peewee import fn, Entity
from pathlib2 import Path
import subprocess
from termcolor import colored


from pyjoplin.models import Note, NoteIndex, database as db
from pyjoplin.configuration import config
from pyjoplin import notification

path_repo = Path.home() / 'Backup/joplin'

printc = lambda x: print(colored(x, 'cyan'))

notes = Note.select().order_by(Note.title)
print("Listing empty notes:")
for note in notes:
    if not note.body:
        printc('Empty: %s %s' % (note.id, note.title))
        notefile = '%s.md' % note.id

        cmd = 'git rev-list HEAD -- %s ' % notefile
        out = subprocess.check_output(cmd, shell=True, cwd=str(path_repo))
        for sha in out.strip().split('\n'):
            cmd = 'git show %s:%s' % (sha, notefile)
            note_content = subprocess.check_output(cmd, shell=True, cwd=str(path_repo))
            filtered_lines = list()
            for idx, line in enumerate(note_content.split('\n')):
                if idx in [0, 1]:
                    continue