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
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")
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
def setup(): """ Do setup required by pyjoplin for interfacing with desktop and terminal app NOTE: db is shared by terminal and desktop apps :return: """ # Substitute CLI database by symbolic link to desktop database.sqlite subprocess.call( "rm database.sqlite", shell=True, cwd=os.path.expanduser("~/.config/joplin/") ) subprocess.call( "ln -s ../joplin-desktop/database.sqlite", shell=True, cwd=os.path.expanduser("~/.config/joplin/"), ) if config.DO_NOTIFY: notification.show("Setup succeeded")
def from_file(self, file_path): """ Populate this note title, notebook and body from a text file NOTE: This does not save the note in the database, just changes its content :param file_path: :return: """ # Save new content to Notes table # NOTE: FTS entry is automatically updated within .save() # Check empty case first, for removal if os.path.getsize(file_path) <= 1: # The file is empty, should trigger removal # NOTE: I don't remember now how removal worked, so trying the below self.title = "" self.body = "" return # NOTE: # There is no `from_string` method because I did not need that yet # Besides, processing from a string vs a file may have a couple of nits # See https://stackoverflow.com/questions/7472839/python-readline-from-a-string with open(file_path, "r", encoding="utf-8") as f: # Get UID from first line uid_line = f.readline().strip() assert uid_line == self.id # Get summary from second line self.title = f.readline().strip() # Get notebook name from third line notebook_name_line = f.readline().strip() try: assert notebook_name_line.startswith("#") except AssertionError: notification.show_error( "Bad notebook line format", message="Lines is: %s\nShould start with #" % notebook_name_line, ) raise RuntimeError("Bad notebook line format") notebook_name = notebook_name_line[1:] # React to notebook changes from text editor try: notebook = Folder.get(Folder.title == notebook_name) if self.parent is not None and notebook.id != self.parent: previous_notebook = Folder.get(Folder.id == self.parent) notification.show( "Notebook changed", note_title=self.title, message="Changed from #%s to #%s" % (previous_notebook.title, notebook.title), ) self.parent = notebook.id except Folder.DoesNotExist: previous_notebook = Folder.get(Folder.id == self.parent) notification.show_error( "Notebook not found", message="#%s\nSaving to previous notebook #%s instead" % (notebook_name, previous_notebook.title), ) # Strip dates line f.readline() # mdate f.readline() # cdate # Assert white line afterwards assert not f.readline().strip() # Read rest of file as body self.body = f.read().strip()
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)
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)