def __init__(self, window): self.rm_client = RemarkableClient() # Define app settings font_size = 38 row_height = 30 window_width = 750 window_height = 650 # Subscribe to events self.rm_client.listen_sign_in_event(self) # Window settings window.title("RemaPy Explorer") # Try to start remapy always on the first screen and in the middle. # We assume a resolution width of 1920... if 1920 is too large use # the real resolution x = min(window.winfo_screenwidth(), 1920) / 2 - (window_width / 2) y = (window.winfo_screenheight() / 2) - (window_height / 2) window.geometry("%dx%d+%d+%d" % (window_width, window_height, x, y)) # Create different tabs on notebook self.notebook = ttk.Notebook(window) self.notebook.pack(expand=1, fill="both") frame = ttk.Frame(self.notebook) self.file_explorer = FileExplorer(frame, window, font_size=font_size, row_height=row_height) self.notebook.add(frame, text="File Explorer") frame = ttk.Frame(self.notebook) self.notebook.add(frame, text="Backup", state="hidden") frame = ttk.Frame(self.notebook) self.notebook.add(frame, text="Zotero", state="hidden") frame = ttk.Frame(self.notebook) self.notebook.add(frame, text="Mirror", state="hidden") frame = ttk.Frame(self.notebook) self.notebook.add(frame, text="SSH", state="hidden") frame = ttk.Frame(self.notebook) self.settings = Settings(frame, font_size) self.notebook.add(frame, text="Settings") frame = ttk.Frame(self.notebook) self.about = About(frame) self.notebook.add(frame, text="About") # Try to sign in to the rm cloud without a onetime code i.e. we # assume that the user token is already available. If it is not # possible we get a signal to disable "My remarkable" and settings # are shown... self.rm_client.sign_in()
def __init__(self, metadata, parent=None): self.metadata = metadata self._parent = parent self._children = [] self.path = get_path(self.id()) self.path_remapy = get_path_remapy(self.id()) self.path_metadata_local = get_path_metadata_local(self.id()) self.rm_client = RemarkableClient() self.state_listener = []
def __init__(self, root, window, font_size=14, row_height=14): self.root = root self.window = window self.app_dir = os.path.dirname(__file__) self.icon_dir = os.path.join(self.app_dir, 'icons/') self._cached_icons = {} self._icon_lock = threading.Lock() self.row_height = row_height # Create tkinter elements self.nodes = dict() self.rm_client = RemarkableClient() self.item_manager = ItemManager() self.tree_style = ttk.Style() self.tree_style.configure("remapy.style.Treeview", highlightthickness=0, bd=0, font=font_size, rowheight=row_height) self.tree_style.configure("remapy.style.Treeview.Heading", font=font_size) self.tree_style.layout("remapy.style.Treeview", [('remapy.style.Treeview.treearea', {'sticky': 'nswe'})]) self.upper_frame = tk.Frame(root) self.upper_frame.pack(expand=True, fill=tk.BOTH) self.label_offline = tk.Label(window, fg="#f44336", font='Arial 13 bold') self.label_offline.place(relx=0.5, y=12, anchor="center") self.entry_filter = None self.entry_filter_var = tk.StringVar() self.entry_filter_var.trace("w", self.filter_changed_event_handler) self.entry_filter = EntryWithPlaceholder(window, "Filter...", textvariable=self.entry_filter_var) self.entry_filter.place(relx=1.0, y=12, anchor="e") # Add tree and scrollbars self.tree = ttk.Treeview(self.upper_frame, style="remapy.style.Treeview") self.tree.pack(side=tk.LEFT, expand=True, fill=tk.BOTH) self.tree.bind("<FocusOut>", self.tree_focus_out_event_handler) self.tree.bind("<FocusIn>", self.tree_focus_in_event_handler) self.vsb = ttk.Scrollbar(self.upper_frame, orient="vertical", command=self.tree.yview) self.vsb.pack(side=tk.LEFT, fill='y') self.tree.configure(yscrollcommand=self.vsb.set) self.hsb = ttk.Scrollbar(root, orient="horizontal", command=self.tree.xview) self.hsb.pack(fill='x') self.tree.configure(xscrollcommand=self.hsb.set) self.tree["columns"]=("#1","#2") self.tree.column("#0", minwidth=250) self.tree.column("#1", width=180, minwidth=180, stretch=tk.NO) self.tree.column("#2", width=150, minwidth=150, anchor="center", stretch=tk.NO) self.tree.heading("#0",text="Name", anchor="center") self.tree.heading("#1", text="Date modified", anchor="center") self.tree.heading("#2", text="Current Page", anchor="center") self.tree.tag_configure('move', background='#FF9800') self.tree.focus_set() self.window.bind("<Escape>", self.key_binding_escape) # Context menu on right click # Check out drag and drop: https://stackoverflow.com/questions/44887576/how-can-i-create-a-drag-and-drop-interface self.tree.bind("<Button-3>", self.tree_right_click) self.context_menu =tk.Menu(root, tearoff=0, font=font_size) self.context_menu.add_command(label='Open', accelerator="Return", command=self.btn_open_item_click) self.context_menu.add_command(label='Open annotated pages ', command=self.btn_open_oap_item_click) self.context_menu.add_command(label='Open raw', command=self.btn_open_item_original_click) self.context_menu.add_command(label='Open folder', command=self.btn_open_in_file_explorer) self.context_menu.add_separator() self.context_menu.add_command(label='ReSync', accelerator="F5", command=self.btn_resync_item_click) self.context_menu.add_command(label='Toggle bookmark', accelerator="Ctrl+B", command=self.btn_toggle_bookmark) self.context_menu.add_command(label='Rename...', accelerator="F2", command=self.btn_rename_item_click) self.context_menu.add_command(label='Delete', accelerator="Del", command=self.btn_delete_item_click) self.context_menu.add_command(label='Restore', command=self.btn_restore_item_click) self.context_menu.add_separator() self.context_menu.add_command(label='Paste', accelerator="Ctrl+V", command=self.btn_paste_async_click) self.tree.bind("<Double-1>", self.tree_double_click) # Footer self.lower_frame = tk.Frame(root) self.lower_frame.pack(side=tk.BOTTOM, anchor="w", fill=tk.X) self.lower_frame_left = tk.Frame(self.lower_frame) self.lower_frame_left.pack(side=tk.LEFT) self.btn_sync = tk.Button(self.lower_frame_left, text="Sync", width=10, command=self.btn_sync_click) self.btn_sync.pack(anchor="w") self.btn_resync = tk.Button(self.lower_frame_left, text="ReSync", width=10, command=self.btn_resync_click) self.btn_resync.pack(anchor="w") self.lower_frame_right = tk.Frame(self.lower_frame) self.lower_frame_right.pack(side=tk.LEFT, expand=True, fill=tk.X) self.log_widget = tk.scrolledtext.ScrolledText(self.lower_frame_right, height=3) self.log_widget.insert(tk.END, "RemaPy Explorer v0.1") self.log_widget.config(state=tk.DISABLED) self.log_widget.pack(expand=True, fill=tk.X) self.rm_client.listen_sign_in_event(self)
class FileExplorer(object): """ Main window of RemaPy which displays the tree structure of all your rm documents and collections. """ def __init__(self, root, window, font_size=14, row_height=14): self.root = root self.window = window self.app_dir = os.path.dirname(__file__) self.icon_dir = os.path.join(self.app_dir, 'icons/') self._cached_icons = {} self._icon_lock = threading.Lock() self.row_height = row_height # Create tkinter elements self.nodes = dict() self.rm_client = RemarkableClient() self.item_manager = ItemManager() self.tree_style = ttk.Style() self.tree_style.configure("remapy.style.Treeview", highlightthickness=0, bd=0, font=font_size, rowheight=row_height) self.tree_style.configure("remapy.style.Treeview.Heading", font=font_size) self.tree_style.layout("remapy.style.Treeview", [('remapy.style.Treeview.treearea', {'sticky': 'nswe'})]) self.upper_frame = tk.Frame(root) self.upper_frame.pack(expand=True, fill=tk.BOTH) self.label_offline = tk.Label(window, fg="#f44336", font='Arial 13 bold') self.label_offline.place(relx=0.5, y=12, anchor="center") self.entry_filter = None self.entry_filter_var = tk.StringVar() self.entry_filter_var.trace("w", self.filter_changed_event_handler) self.entry_filter = EntryWithPlaceholder(window, "Filter...", textvariable=self.entry_filter_var) self.entry_filter.place(relx=1.0, y=12, anchor="e") # Add tree and scrollbars self.tree = ttk.Treeview(self.upper_frame, style="remapy.style.Treeview") self.tree.pack(side=tk.LEFT, expand=True, fill=tk.BOTH) self.tree.bind("<FocusOut>", self.tree_focus_out_event_handler) self.tree.bind("<FocusIn>", self.tree_focus_in_event_handler) self.vsb = ttk.Scrollbar(self.upper_frame, orient="vertical", command=self.tree.yview) self.vsb.pack(side=tk.LEFT, fill='y') self.tree.configure(yscrollcommand=self.vsb.set) self.hsb = ttk.Scrollbar(root, orient="horizontal", command=self.tree.xview) self.hsb.pack(fill='x') self.tree.configure(xscrollcommand=self.hsb.set) self.tree["columns"]=("#1","#2") self.tree.column("#0", minwidth=250) self.tree.column("#1", width=180, minwidth=180, stretch=tk.NO) self.tree.column("#2", width=150, minwidth=150, anchor="center", stretch=tk.NO) self.tree.heading("#0",text="Name", anchor="center") self.tree.heading("#1", text="Date modified", anchor="center") self.tree.heading("#2", text="Current Page", anchor="center") self.tree.tag_configure('move', background='#FF9800') self.tree.focus_set() self.window.bind("<Escape>", self.key_binding_escape) # Context menu on right click # Check out drag and drop: https://stackoverflow.com/questions/44887576/how-can-i-create-a-drag-and-drop-interface self.tree.bind("<Button-3>", self.tree_right_click) self.context_menu =tk.Menu(root, tearoff=0, font=font_size) self.context_menu.add_command(label='Open', accelerator="Return", command=self.btn_open_item_click) self.context_menu.add_command(label='Open annotated pages ', command=self.btn_open_oap_item_click) self.context_menu.add_command(label='Open raw', command=self.btn_open_item_original_click) self.context_menu.add_command(label='Open folder', command=self.btn_open_in_file_explorer) self.context_menu.add_separator() self.context_menu.add_command(label='ReSync', accelerator="F5", command=self.btn_resync_item_click) self.context_menu.add_command(label='Toggle bookmark', accelerator="Ctrl+B", command=self.btn_toggle_bookmark) self.context_menu.add_command(label='Rename...', accelerator="F2", command=self.btn_rename_item_click) self.context_menu.add_command(label='Delete', accelerator="Del", command=self.btn_delete_item_click) self.context_menu.add_command(label='Restore', command=self.btn_restore_item_click) self.context_menu.add_separator() self.context_menu.add_command(label='Paste', accelerator="Ctrl+V", command=self.btn_paste_async_click) self.tree.bind("<Double-1>", self.tree_double_click) # Footer self.lower_frame = tk.Frame(root) self.lower_frame.pack(side=tk.BOTTOM, anchor="w", fill=tk.X) self.lower_frame_left = tk.Frame(self.lower_frame) self.lower_frame_left.pack(side=tk.LEFT) self.btn_sync = tk.Button(self.lower_frame_left, text="Sync", width=10, command=self.btn_sync_click) self.btn_sync.pack(anchor="w") self.btn_resync = tk.Button(self.lower_frame_left, text="ReSync", width=10, command=self.btn_resync_click) self.btn_resync.pack(anchor="w") self.lower_frame_right = tk.Frame(self.lower_frame) self.lower_frame_right.pack(side=tk.LEFT, expand=True, fill=tk.X) self.log_widget = tk.scrolledtext.ScrolledText(self.lower_frame_right, height=3) self.log_widget.insert(tk.END, "RemaPy Explorer v0.1") self.log_widget.config(state=tk.DISABLED) self.log_widget.pack(expand=True, fill=tk.X) self.rm_client.listen_sign_in_event(self) def _set_online_mode(self, mode): self.btn_sync.config(state=mode) self.btn_resync.config(state=mode) self.context_menu.entryconfig(5, state=mode) self.context_menu.entryconfig(6, state=mode) self.context_menu.entryconfig(7, state=mode) self.context_menu.entryconfig(8, state=mode) self.context_menu.entryconfig(9, state=mode) self.context_menu.entryconfig(11, state=mode) bg = "#ffffff" if mode == "normal" else "#bdbdbd" self.tree_style.configure("remapy.style.Treeview", background=bg) if mode == "normal": self.label_offline.config(text="") else: self.label_offline.config(text="Offline") def log_console(self, text): now = strftime("%H:%M:%S", gmtime()) self.log_widget.config(state=tk.NORMAL) self.log_widget.insert(tk.END, "\n[%s] %s" % (str(now), text)) self.log_widget.config(state=tk.DISABLED) self.log_widget.see(tk.END) # # Tree # def tree_focus_out_event_handler(self, *args): self.window.unbind("<Control-v>") self.window.unbind("<Return>") self.window.unbind("<Delete>") self.window.unbind("<Control-f>") self.window.unbind("<Control-b>") self.window.unbind("<F5>") self.window.unbind("<F2>") def tree_focus_in_event_handler(self, *args): self.window.bind("<Control-f>", self.key_binding_filter) self.window.bind("<Control-v>", self.key_binding_paste) self.window.bind("<Control-b>", self.key_binding_toggle_bookmark) self.window.bind("<F5>", self.key_binding_resync) self.window.bind("<Return>", self.key_binding_return) self.window.bind("<Delete>", self.key_binding_delete) self.window.bind("<F2>", self.key_binding_rename) def sign_in_event_handler(self, event, data): # Also if the login failed (e.g. we are offline) we try again # if we can sync the items (e.g. with old user key) and otherwise # we switch to the offline mode if event == api.remarkable_client.EVENT_SUCCESS or event == api.remarkable_client.EVENT_USER_TOKEN_FAILED: self.btn_sync_click() def key_binding_filter(self, event): self.entry_filter.focus_set() def key_binding_escape(self, event): self.tree.focus_set() children = self.tree.get_children() if len(children) > 0: self.tree.focus(children[0]) else: self.tree.focus() def filter_changed_event_handler(self, placeholder, *args): if self.entry_filter is None: return filter_text = self.entry_filter_var.get() if filter_text == self.entry_filter.placeholder: filter_text = None root = self.item_manager.get_root() self.tree.delete(*self.tree.get_children()) self._update_tree(root, filter_text) def _update_tree(self, item, filter=None): try: is_direct_match = False if not item.is_root(): is_match, is_direct_match = self._match_filter(item, filter) if is_match: self.tree.insert( item.parent().id(), 0 if item.id() != "trash" else 99999, item.id(), open=filter!=None) self._update_tree_item(item) item.add_state_listener(self._update_tree_item) include_all_childs = item.is_collection() and is_direct_match if include_all_childs: filter = None # Sort by name and item type sorted_children = item.children() sorted_children.sort(key=lambda x: str.lower(x.name()), reverse=True) sorted_children.sort(key=lambda x: int(x.is_document()), reverse=True) sorted_children.sort(key=lambda x: int(x.id()=="trash"), reverse=True) for child in sorted_children: self._update_tree(child, filter) except Exception as e: self.log_console("(Warning) Failed to add item %s" % item.id()) print(e) # Try to remove wrong item from tree try: self.tree.delete(item.id()) except: pass def _match_filter(self, item, filter): """ Returns whether we have a match on this path (to include all parent folders) and whether the given item was the matching one. """ if filter is None or filter == "": return True, True if filter.startswith("!b "): bookmarked_only = True text_filter = filter[3:] elif filter == "!b": bookmarked_only = True text_filter = "" else: bookmarked_only = False text_filter = filter is_match = (text_filter.lower() in item.name().lower()) if bookmarked_only: is_match = is_match and item.bookmarked() if is_match: return is_match, True for child in item.children(): child_match, _ = self._match_filter(child, filter) if child_match: return child_match, False return False, False def tree_right_click(self, event): selected_ids = self.tree.selection() if selected_ids: items = [self.item_manager.get_item(id) for id in selected_ids] for item in items: for possile_child in items: if not item.is_parent_of(possile_child): continue messagebox.showerror( "Invalid operation", "Your selection is invalid. You can not perform an \ action on a folder and one of its child items.") return self.context_menu.tk_popup(event.x_root, event.y_root) pass else: # mouse pointer not over item pass def _update_tree_item(self, item): if item.state == model.item.STATE_DELETED: self.tree.delete(item.id()) else: icon = self._get_icon(item) self.tree.item( item.id(), image=icon, text=" " + item.name(), values=( item.modified_time().strftime("%Y-%m-%d %H:%M:%S"), item.current_page())) def _get_icon(self, item): if item.is_collection(): if item.id() == "trash": return self._create_tree_icon("trash") if item.state == model.item.STATE_SYNCED: return self._create_tree_icon("collection", item.bookmarked()) else: return self._create_tree_icon("collection_syncing") if item.state == model.document.STATE_NOT_SYNCED: return self._create_tree_icon("cloud") elif item.state == model.item.STATE_SYNCING: return self._create_tree_icon("document_syncing") if item.state == model.item.STATE_SYNCED: if item.type == model.document.TYPE_PDF: return self._create_tree_icon("pdf", item.bookmarked()) elif item.type == model.document.TYPE_EPUB: return self._create_tree_icon("epub", item.bookmarked()) else: return self._create_tree_icon("notebook", item.bookmarked()) if item.state == model.document.STATE_OUT_OF_SYNC: if item.type == model.document.TYPE_PDF: return self._create_tree_icon("pdf_out_of_sync") elif item.type == model.document.TYPE_EPUB: return self._create_tree_icon("epub_out_of_sync") else: return self._create_tree_icon("notebook_out_of_sync") return self._create_tree_icon("weird") def _create_tree_icon(self, name, bookmarked=False): key = "%s_%s" % (name, bookmarked) if key in self._cached_icons: return self._cached_icons[key] # If possible return icon from cache with self._icon_lock: # Double check if key is in cached icons if key in self._cached_icons: return self._cached_icons[key] icon_size = self.row_height-4 path = "%s%s.png" % (self.icon_dir, name) icon = Image.open(path) icon = icon.resize((icon_size, icon_size)) if bookmarked: icon_star = Image.open("%s%s.png" % (self.icon_dir, "star")) icon_star = icon_star.resize((icon_size, icon_size)) icon.paste(icon_star, None, icon_star) self._cached_icons[key] = itk.PhotoImage(icon) return self._cached_icons[key] def key_binding_rename(self, event): self.btn_rename_item_click() def btn_rename_item_click(self): selected_ids = self.tree.selection() if len(selected_ids) != 1: messagebox.showerror("Error", "Select exactly one item to rename.", icon='error') return item = self.item_manager.get_item(selected_ids[0]) if item.name() == "Quick sheets" and item.parent().is_root(): messagebox.showerror("Error", "You can not rename the Quick sheets.", icon='error') return if item.name() == "Trash" and item.parent().is_root(): messagebox.showerror("Error", "You can not rename the Trash.", icon='error') return name = simpledialog.askstring('Rename', 'Enter new name', initialvalue=item.name()) if name is None: return item.rename(name) def key_binding_resync(self, event): self.btn_resync_item_click() def btn_resync_item_click(self): self._sync_selection_async( force=True, open_file=False, open_original=False) def tree_double_click(self, event): selected_ids = self.tree.selection() item = self.item_manager.get_item(selected_ids[0]) if item.is_document(): self._sync_selection_async( force=False, open_file=True, open_original=False) def key_binding_return(self, event): self.btn_open_item_click() def btn_open_item_click(self): self._sync_selection_async( force=False, open_file=True, open_original=False) def btn_open_oap_item_click(self): self._sync_selection_async( force=False, open_file=True, open_oap=True) def btn_open_item_original_click(self): self._sync_selection_async( force=False, open_file=True, open_original=True) def btn_resync_click(self): if self.is_online: message = "Do you really want to delete ALL local files and download ALL documents again?" else: message = "Do you really want resync without a connection to the remarkable cloud?" result = messagebox.askquestion("Warning", message, icon='warning') if result != "yes": return # Clean everything, also if some (old) things exist shutil.rmtree(utils.config.PATH, ignore_errors=True) Path(utils.config.PATH).mkdir(parents=True, exist_ok=True) self.item_manager.traverse_tree( fun=lambda item: item.update_state() ) # And sync again self.btn_sync_click() def btn_sync_click(self): self.log_console("Syncing all documents...") root, self.is_online = self.item_manager.get_root(force=True) if self.is_online: self._set_online_mode("normal") else: self.log_console("OFFLINE MODE: No connection to the remarkable cloud") self._set_online_mode("disabled") self.tree.delete(*self.tree.get_children()) self._update_tree(root) self._sync_items_async([self.item_manager.get_root()], force=False, open_file=False, open_original=False, open_oap=False) def _sync_selection_async(self, force=False, open_file=False, open_original=False, open_oap=False): selected_ids = self.tree.selection() items = [self.item_manager.get_item(id) for id in selected_ids] self._sync_items_async(items, force, open_file, open_original, open_oap) def _sync_items_async(self, items, force, open_file, open_original, open_oap): """ To keep the gui responsive... """ thread = threading.Thread(target=self._sync_items, args=(items, force, open_file, open_original, open_oap)) thread.start() def _sync_items(self, items, force, open_file, open_original, open_oap): q = queue.Queue() threads = [] def worker(): while True: item = q.get() if item is None: break try: self._sync_and_open_item(item, force, open_file, open_original, open_oap) except Exception as e: if open_file: self.log_console("(Error) Could not open '%s'" % item.name()) else: self.log_console("(Error) Could not sync '%s'" % item.name()) print(e) q.task_done() num_worker_threads = 10 for i in range(num_worker_threads): t = threading.Thread(target=worker) t.start() threads.append(t) # Add all items and child items for item in items: self.item_manager.traverse_tree(fun=q.put, item = item) q.join() # stop workers for i in range(num_worker_threads): q.put(None) for t in threads: t.join() def _sync_and_open_item(self, item, force, open_file, open_original, open_oap): if item.state == model.item.STATE_SYNCING: self.log_console("Already syncing '%s'" % item.full_name()) return if (force or item.state != model.item.STATE_SYNCED) and not item.is_root(): item.sync() if item.is_document(): self.log_console("Synced '%s'" % item.full_name()) if open_file and item.is_document(): if open_original: file_to_open = item.orig_file() elif open_oap: file_to_open = item.oap_file() if file_to_open == None: messagebox.showinfo("Information", "Document is not annotated.", icon='info') return else: file_to_open = item.ann_or_orig_file() if sys.platform == "win32": os.startfile(os.path.normpath(file_to_open)) else: if file_to_open.endswith(".pdf"): try: current_page = 0 if open_oap else item.current_page() subprocess.call(["evince", "-i", str(current_page), file_to_open]) except: subprocess.call(["xdg-open", file_to_open]) else: subprocess.call(["xdg-open", file_to_open]) # # Delete # def key_binding_delete(self, event): self.btn_delete_item_click() def btn_delete_item_click(self): selected_ids = self.tree.selection() items = [self.item_manager.get_item(id) for id in selected_ids] count = [0, 0] for item in items: if item.is_document(): count[0] += 1 continue child_count = item.get_exact_children_count() count = np.add(count, child_count) message = "Do you really want to delete (or trash) %d collection(s) and %d file(s)?" % (count[1], count[0]) result = messagebox.askquestion("Delete", message, icon='warning') if result != "yes": return def run(): for item in items: if item.name() == "Quick sheets" and item.parent().is_root(): self.log_console("(Warning) You can not delete the Quick sheets.") continue if item.name() == "Trash" and item.parent().is_root(): self.log_console("(Warning) You can not delete the trash.") continue if item.parent().id() == "trash": item.delete() self.log_console("Deleted %s" % item.full_name()) else: trash = self.item_manager.trash self._move(item, trash) threading.Thread(target=run).start() # # RESTORE # def btn_restore_item_click(self): selected_ids = self.tree.selection() items = [self.item_manager.get_item(id) for id in selected_ids] def run(): for item in items: if item.parent().id() != "trash": self.log_console("(Warning) Restore of '%s' not necessary." % item.full_name()) continue self._move(item, self.item_manager.root) threading.Thread(target=run).start() def _move(self, item, new_parent): old_parent_id = item.parent().id() # Move on cloud item.move(new_parent) # Remove from old parent (tree view) old_parent_children = list(self.tree.get_children(old_parent_id)) old_parent_children.remove(item.id()) self.tree.set_children(old_parent_id, *old_parent_children) # Add to trash (tree view) new_parent_children = list(self.tree.get_children(new_parent.id())) new_parent_children.append(item.id()) self.tree.set_children(new_parent.id(), *new_parent_children) self.log_console("Moved '%s' into '%s'" % (item.full_name(), new_parent.full_name())) # # Copy, Paste, Cut # def key_binding_paste(self, event): self.btn_paste_async_click() def btn_paste_async_click(self): selected_ids = self.tree.selection() if len(selected_ids) > 1: messagebox.showerror("Paste error", "Can paste only into one collection.") return elif len(selected_ids) == 1: item = self.item_manager.get_item(selected_ids[0]) parent_id = str(item.parent().id() if item.is_document() else item.id()) else: parent_id = "" def is_file(path): if not os.path.exists(path): return None elif path.endswith(".pdf"): return "pdf" elif path.endswith(".epub"): return "epub" return None def is_url(url): return url.startswith("http") # Some versions of nautilus include "x-special/nautilus-clipboard file://..." # Or dolphin simple adds "file://..." # See also issue #11 paths = self.root.clipboard_get().split("\n") paths = [path.replace("file://", "") for path in paths] paths = list(filter(lambda path: is_file(path) != None or is_url(path), paths)) if len(paths) <= 0: messagebox.showerror( "Failed to copy from clipboard", "The given clipboard is invalid. Only .pdf, .epub and urls are supported.\n\n%s" % self.root.clipboard_get()) return def run(clipboard): id = str(uuid.uuid4()) filetype = is_file(clipboard) if filetype != None: name = os.path.splitext(os.path.basename(clipboard))[0] self.tree.insert( parent_id, 9999, id, text= " " + name, image=self._create_tree_icon("document_upload")) with open(clipboard, "rb") as f: data = f.read() elif is_url(path): try: import pdfkit self.log_console("Converting webpage '%s'. This could take a few minutes." % clipboard) name = clipboard self.tree.insert( parent_id, 9999, id, text= " " + name, image=self._create_tree_icon("document_upload")) options = { # Here we can manually set some cookies to # for example automatically accept terms of usage etc. 'cookie': [ ('DSGVO_ZUSAGE_V1', 'true') ] } data = pdfkit.from_url(clipboard, False, options=options) filetype = "pdf" except Exception as e: messagebox.showerror( "Failed to convert html to pdf", "Please ensure that you installed pdfkit and wkhtmltopdf correctly https://pypi.org/project/pdfkit/") self.tree.delete(id) return # Show new item in tree self.log_console("Upload document %s..." % name) # Upload item = self.item_manager.upload_file( id, parent_id, name, filetype, data, self._update_tree_item) self.log_console("Successfully uploaded %s" % item.full_name()) for path in paths: threading.Thread(target=run, args=[path]).start() def btn_open_in_file_explorer(self): selected_ids = self.tree.selection() items = [self.item_manager.get_item(id) for id in selected_ids] for item in items: if item.is_collection(): continue if sys.platform == "win32": os.startfile(os.path.normpath(item.path_remapy)) else: subprocess.call(('xdg-open', item.path_remapy)) def key_binding_toggle_bookmark(self, event): self.btn_toggle_bookmark() def btn_toggle_bookmark(self): selected_ids = self.tree.selection() items = [self.item_manager.get_item(id) for id in selected_ids] def run(): for item in items: item.set_bookmarked(not item.bookmarked()) threading.Thread(target=run).start()
class Main(object): def __init__(self, window): self.rm_client = RemarkableClient() # Define app settings scale = cfg.get("scaling", 1 / window.tk.call('tk', 'scaling')) window.tk.call('tk', 'scaling', 1 / scale) window_width = 750 * scale window_height = 700 * scale # Subscribe to events self.rm_client.listen_sign_in_event(self) # Window settings window.title("RemaPy Explorer") # Try to start remapy always on the first screen and in the middle. # We assume a resolution width of 1920... if 1920 is too large use # the real resolution x = min(window.winfo_screenwidth(), 1920) / 2 - (window_width / 2) y = (window.winfo_screenheight() / 2) - (window_height / 2) window.geometry("%dx%d+%d+%d" % (window_width, window_height, x, y)) # Create different tabs on notebook self.notebook = ttk.Notebook(window) self.notebook.pack(expand=1, fill="both") frame = ttk.Frame(self.notebook) self.file_explorer = FileExplorer(frame, window) self.notebook.add(frame, text="File Explorer") frame = ttk.Frame(self.notebook) self.notebook.add(frame, text="Backup", state="hidden") frame = ttk.Frame(self.notebook) self.notebook.add(frame, text="Zotero", state="hidden") frame = ttk.Frame(self.notebook) self.notebook.add(frame, text="Mirror", state="hidden") frame = ttk.Frame(self.notebook) self.notebook.add(frame, text="SSH", state="hidden") frame = ttk.Frame(self.notebook) self.settings = Settings(frame) self.notebook.add(frame, text="Settings") frame = ttk.Frame(self.notebook) self.about = About(frame) self.notebook.add(frame, text="About") # Try to sign in to the rm cloud without a onetime code i.e. we # assume that the user token is already available. If it is not # possible we get a signal to disable "My remarkable" and settings # are shown... self.rm_client.sign_in() # # EVENT HANDLER # def sign_in_event_handler(self, event, data): # If we fail to get a user token, we are e.g. offline. So we continue # and try if we can get it later; otherwise we go into an offline mode if event == api.remarkable_client.EVENT_SUCCESS or event == api.remarkable_client.EVENT_USER_TOKEN_FAILED: self.notebook.tab(0, state="normal") else: self.notebook.tab(0, state="disabled")
def __init__(self, ): self.rm_client = RemarkableClient() self.root = None self.trash = None
class ItemManager(metaclass=Singleton): """ The ItemManager keeps track of all the collections and documents that are stored in your rm cloud. Load and create items through this class. It is a singleton such that it is ensured that access to items goes through the same tree structure. """ def __init__(self, ): self.rm_client = RemarkableClient() self.root = None self.trash = None def get_root(self, force=False): """ Get root node of tree from cache or download it from the rm cloud. If you are offline, we load the stored tree from last time. Note that if we are online we sync all files with the rm cloud i.e. delete old local files. """ if not self.root is None and not force: return self.root metadata_list, is_online = self._get_metadata_list() self._clean_local_items(metadata_list) self.root, self.trash = self._create_tree(metadata_list) return self.root, is_online def get_item(self, id, item=None): """ Get item object for given id. If item metadata is not already downloaded, it is downloaded beforehand. """ self.get_root() item = self.root if item is None else item if item.id() == id: return item for child in item.children(): found = self.get_item(id, child) if found != None: return found return None def create_backup(self, backup_path): # Create folder structure self.traverse_tree(fun=lambda item: item.create_backup(backup_path), document=False, collection=True) # And copy files into it self.traverse_tree(fun=lambda item: item.create_backup(backup_path), document=True, collection=False) def upload_file(self, id, parent_id, name, filetype, data, state_listener=None): metadata, mf = self._prepare_new_document_zip(id, name, data, file_type=filetype, parent_id=parent_id) # Upload file into cloud metadata = self.rm_client.upload(id, metadata, mf) # Download again to ensure that metadata is correct parent = self.get_item(parent_id) item = self._create_item(metadata, parent) if state_listener != None: item.add_state_listener(state_listener) # Download again to get it correctly item.sync() return item def traverse_tree(self, fun, item=None, document=True, collection=True): """ Traverse item tree (bottom up) and call fun for item depending on whether document=True and colleciton=True. """ item = self.get_root() if item == None else item for child in item.children(): self.traverse_tree(fun, child, document, collection) if (item.is_document() and document) or (item.is_collection() and collection): fun(item) def _create_item(self, metadata, parent): if metadata["Type"] == "CollectionType": new_object = Collection(metadata, parent) elif metadata["Type"] == "DocumentType": new_object = Document(metadata, parent) else: raise Exception("Unknown type %s" % metadata["Type"]) parent.add_child(new_object) return new_object def _get_metadata_list(self): try: metadata_list = self.rm_client.list_items() return metadata_list, metadata_list != None except: metadata_list = [] for local_id in os.listdir(utils.config.PATH): metadata_path = model.item.get_path_metadata_local(local_id) with open(metadata_path, 'r') as file: metadata_content = file.read().replace('\n', '') metadata = json.loads(metadata_content) metadata_list.append(metadata) return metadata_list, False def _clean_local_items(self, metadata_list): online_ids = [metadata["ID"] for metadata in metadata_list] for local_id in os.listdir(utils.config.PATH): if local_id in online_ids: continue local_file_or_folder = "%s/%s" % (utils.config.PATH, local_id) if os.path.isfile(local_file_or_folder): os.remove(local_file_or_folder) else: shutil.rmtree(local_file_or_folder) print("Deleted local item %s" % local_id) def _create_tree(self, metadata_list): # Create a dummy root object where everything starts with parent # "". This parent "" should not be changed as it is also used in # the rm cloud. Additionally with version 2.2 of the software # every tablet implicitly includes a trash. root = Collection(None, None) trash_metadata = { "ID": "trash", "Parent": "", "VissibleName": "Trash", "Version": 1, "Bookmarked": False, "Type": "CollectionType", "ModifiedClient": "2000-01-01T00:00:00.000000Z" } trash = self._create_item(trash_metadata, root) metadata_list.append(trash_metadata) # We do this for every element, because _create_item_and_parents # only ensures that all parents already exist items = {"": root, "trash": trash} lookup_table = {} for i in range(len(metadata_list)): lookup_table[metadata_list[i]["ID"]] = i for i in range(len(metadata_list)): self._create_item_and_parents(i, metadata_list, items, lookup_table) return root, trash def _create_item_and_parents(self, i, metadata_list, items, lookup_table): metadata = metadata_list[i] parent_id = metadata["Parent"] if i < 0 or len(metadata_list) <= 0 or metadata["ID"] in items: return if not parent_id in items: if not parent_id in lookup_table: print("(Warning) No parent for item %s" % metadata["VissibleName"]) parent_id = "" else: parent_pos = lookup_table[parent_id] self._create_item_and_parents(parent_pos, metadata_list, items, lookup_table) parent = items[parent_id] new_object = self._create_item(metadata, parent) items[new_object.id()] = new_object def _prepare_new_document_zip(self, id, name, data, file_type, parent_id=""): # .content file content_file = json.dumps({ "dummyDocument": False, "extraMetadata": { "LastBrushColor": "Black", "LastBrushThicknessScale": "2", "LastColor": "Black", "LastEraserThicknessScale": "2", "LastEraserTool": "Eraser", "LastPen": "Finelinerv2", "LastPenColor": "Black", "LastPenThicknessScale": "2", "LastPencil": "SharpPencil", "LastPencilColor": "Black", "LastPencilThicknessScale": "2", "LastTool": "Finelinerv2", "ThicknessScale": "2", "LastFinelinerv2Size": "1", }, "fileType": file_type, "pageCount": 0, "pages": [], "fontName": "", "lastOpenedPage": 0, "lineHeight": -1, "margins": 180, "orientation": "portrait", "textScale": 1, "transform": { "m11": 1, "m12": 0, "m13": 0, "m21": 0, "m22": 1, "m23": 0, "m31": 0, "m32": 0, "m33": 1, } }) # metadata metadata = { "ID": id, "Parent": parent_id, "VissibleName": name, "Type": "DocumentType", "Version": 1, "ModifiedClient": model.item.now_rfc3339(), "CurrentPage": 0, "Bookmarked": False, # "Message": "", # "Success": True, # "BlobURLGet": "", # "BlobURLGetExpires": "", # "BlobURLPut": "", # "BlobURLPutExpires": "" } mf = BytesIO() mf.seek(0) with ZipFile(mf, mode='w', compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr("%s.%s" % (id, file_type), data) zf.writestr("%s.content" % id, content_file) zf.writestr("%s.pagedata" % id, "") # with open("test.zip", "wb") as f: # f.write(mf.getvalue()) mf.seek(0) return metadata, mf
def __init__(self, root, font_size): self.rm_client = RemarkableClient() self.item_manager = ItemManager() root.grid_columnconfigure(4, minsize=180) root.grid_rowconfigure(1, minsize=50) root.grid_rowconfigure(2, minsize=30) root.grid_rowconfigure(3, minsize=30) root.grid_rowconfigure(4, minsize=30) root.grid_rowconfigure(6, minsize=50) root.grid_rowconfigure(7, minsize=30) root.grid_rowconfigure(8, minsize=30) root.grid_rowconfigure(9, minsize=50) # gaps between columns label = tk.Label(root, text=" ") label.grid(row=1, column=1) label = tk.Label(root, text=" ") label.grid(row=1, column=3) label = tk.Label(root, text=" ") label.grid(row=1, column=5) label = tk.Label(root, text="Authentication", font="Helvetica 14 bold") label.grid(row=1, column=2, sticky="W") self.onetime_code_link = "https://my.remarkable.com#desktop" self.label_onetime_code = tk.Label( root, justify="left", anchor="w", fg="blue", cursor="hand2", text="\nDownload one-time code from \n" + self.onetime_code_link) self.label_onetime_code.grid(row=2, column=7, sticky="SW") self.label_onetime_code.bind( "<Button-1>", lambda e: webbrowser.open_new(self.onetime_code_link)) label = tk.Label(root, justify="left", anchor="w", text="Status: ") label.grid(row=2, column=2, sticky="W") self.label_auth_status = tk.Label(root, text="Unknown") self.label_auth_status.grid(row=2, column=4, sticky="W") label = tk.Label(root, justify="left", anchor="w", text="One-time code:") label.grid(row=3, column=2, sticky="W") self.entry_onetime_code_text = tk.StringVar() self.entry_onetime_code = tk.Entry( root, textvariable=self.entry_onetime_code_text) self.entry_onetime_code.grid(row=3, column=4, sticky="W") self.btn_sign_in = tk.Button(root, text="Sign In", command=self.btn_sign_in_click, width=17) self.btn_sign_in.grid(row=4, column=4, sticky="W") label = tk.Label(root, text="General", font="Helvetica 14 bold") label.grid(row=6, column=2, sticky="W") label = tk.Label(root, text="Templates path:") label.grid(row=7, column=2, sticky="W") self.entry_templates_text = tk.StringVar() self.entry_templates_text.set(cfg.get("general.templates", default="")) self.entry_templates = tk.Entry(root, textvariable=self.entry_templates_text) self.entry_templates.grid(row=7, column=4, sticky="W") label = tk.Label( root, justify="left", anchor="w", text= "A local folder that contains all template PNG files. \nYou can copy the template files from your tablet: \n'/usr/share/remarkable'" ) label.grid(row=7, column=7, sticky="W") label = tk.Label(root, text="Backup root path:") label.grid(row=8, column=2, sticky="W") self.backup_root_text = tk.StringVar() backup_root_default = Path.joinpath(Path.home(), "Backup", "Remarkable") backup_root = cfg.get("general.backuproot", default=str(backup_root_default)) self.backup_root_text.set(backup_root) self.entry_backup_root = tk.Entry(root, textvariable=self.backup_root_text) self.entry_backup_root.grid(row=8, column=4, sticky="W") label = tk.Label( root, justify="left", anchor="w", text= "A local folder that will be used as the root folder for backups.") label.grid(row=8, column=7, sticky="W") self.btn_save = tk.Button(root, text="Save", command=self.btn_save_click, width=17) self.btn_save.grid(row=9, column=4, sticky="W") label = tk.Label(root, text="Backup", font="Helvetica 14 bold") label.grid(row=10, column=2, sticky="W") label = tk.Label(root, text="Backup path:") label.grid(row=11, column=2, sticky="W") self.backup_folder_text = tk.StringVar() backup_folder = str(date.today().strftime("%Y-%m-%d")) self.backup_folder_text.set(backup_folder) self.entry_backup_folder = tk.Entry( root, textvariable=self.backup_folder_text) self.entry_backup_folder.grid(row=11, column=4, sticky="W") self.label_backup_progress = tk.Label(root) self.label_backup_progress.grid(row=11, column=6) label = tk.Label( root, justify="left", anchor="w", text= "Copy currently downloaded and annotated PDF files \ninto the given directory. Note that those files can not \nbe restored on the tablet." ) label.grid(row=11, column=7, sticky="W") self.btn_create_backup = tk.Button(root, text="Create backup", command=self.btn_create_backup, width=17) self.btn_create_backup.grid(row=12, column=4, sticky="W") # Subscribe to sign in event. Outer logic (i.e. main) can try to # sign in automatically... self.rm_client.listen_sign_in_event(self)
class Settings(object): def __init__(self, root, font_size): self.rm_client = RemarkableClient() self.item_manager = ItemManager() root.grid_columnconfigure(4, minsize=180) root.grid_rowconfigure(1, minsize=50) root.grid_rowconfigure(2, minsize=30) root.grid_rowconfigure(3, minsize=30) root.grid_rowconfigure(4, minsize=30) root.grid_rowconfigure(6, minsize=50) root.grid_rowconfigure(7, minsize=30) root.grid_rowconfigure(8, minsize=30) root.grid_rowconfigure(9, minsize=50) # gaps between columns label = tk.Label(root, text=" ") label.grid(row=1, column=1) label = tk.Label(root, text=" ") label.grid(row=1, column=3) label = tk.Label(root, text=" ") label.grid(row=1, column=5) label = tk.Label(root, text="Authentication", font="Helvetica 14 bold") label.grid(row=1, column=2, sticky="W") self.onetime_code_link = "https://my.remarkable.com#desktop" self.label_onetime_code = tk.Label( root, justify="left", anchor="w", fg="blue", cursor="hand2", text="\nDownload one-time code from \n" + self.onetime_code_link) self.label_onetime_code.grid(row=2, column=7, sticky="SW") self.label_onetime_code.bind( "<Button-1>", lambda e: webbrowser.open_new(self.onetime_code_link)) label = tk.Label(root, justify="left", anchor="w", text="Status: ") label.grid(row=2, column=2, sticky="W") self.label_auth_status = tk.Label(root, text="Unknown") self.label_auth_status.grid(row=2, column=4, sticky="W") label = tk.Label(root, justify="left", anchor="w", text="One-time code:") label.grid(row=3, column=2, sticky="W") self.entry_onetime_code_text = tk.StringVar() self.entry_onetime_code = tk.Entry( root, textvariable=self.entry_onetime_code_text) self.entry_onetime_code.grid(row=3, column=4, sticky="W") self.btn_sign_in = tk.Button(root, text="Sign In", command=self.btn_sign_in_click, width=17) self.btn_sign_in.grid(row=4, column=4, sticky="W") label = tk.Label(root, text="General", font="Helvetica 14 bold") label.grid(row=6, column=2, sticky="W") label = tk.Label(root, text="Templates path:") label.grid(row=7, column=2, sticky="W") self.entry_templates_text = tk.StringVar() self.entry_templates_text.set(cfg.get("general.templates", default="")) self.entry_templates = tk.Entry(root, textvariable=self.entry_templates_text) self.entry_templates.grid(row=7, column=4, sticky="W") label = tk.Label( root, justify="left", anchor="w", text= "A local folder that contains all template PNG files. \nYou can copy the template files from your tablet: \n'/usr/share/remarkable'" ) label.grid(row=7, column=7, sticky="W") label = tk.Label(root, text="Backup root path:") label.grid(row=8, column=2, sticky="W") self.backup_root_text = tk.StringVar() backup_root_default = Path.joinpath(Path.home(), "Backup", "Remarkable") backup_root = cfg.get("general.backuproot", default=str(backup_root_default)) self.backup_root_text.set(backup_root) self.entry_backup_root = tk.Entry(root, textvariable=self.backup_root_text) self.entry_backup_root.grid(row=8, column=4, sticky="W") label = tk.Label( root, justify="left", anchor="w", text= "A local folder that will be used as the root folder for backups.") label.grid(row=8, column=7, sticky="W") self.btn_save = tk.Button(root, text="Save", command=self.btn_save_click, width=17) self.btn_save.grid(row=9, column=4, sticky="W") label = tk.Label(root, text="Backup", font="Helvetica 14 bold") label.grid(row=10, column=2, sticky="W") label = tk.Label(root, text="Backup path:") label.grid(row=11, column=2, sticky="W") self.backup_folder_text = tk.StringVar() backup_folder = str(date.today().strftime("%Y-%m-%d")) self.backup_folder_text.set(backup_folder) self.entry_backup_folder = tk.Entry( root, textvariable=self.backup_folder_text) self.entry_backup_folder.grid(row=11, column=4, sticky="W") self.label_backup_progress = tk.Label(root) self.label_backup_progress.grid(row=11, column=6) label = tk.Label( root, justify="left", anchor="w", text= "Copy currently downloaded and annotated PDF files \ninto the given directory. Note that those files can not \nbe restored on the tablet." ) label.grid(row=11, column=7, sticky="W") self.btn_create_backup = tk.Button(root, text="Create backup", command=self.btn_create_backup, width=17) self.btn_create_backup.grid(row=12, column=4, sticky="W") # Subscribe to sign in event. Outer logic (i.e. main) can try to # sign in automatically... self.rm_client.listen_sign_in_event(self) # # EVENT HANDLER # def sign_in_event_handler(self, event, config): self.btn_sign_in.config(state="normal") self.entry_onetime_code.config(state="normal") self.btn_create_backup.config(state="disabled") self.btn_save.config(state="disabled") self.entry_backup_root.config(state="disabled") self.entry_backup_folder.config(state="disabled") self.entry_templates.config(state="disabled") if event == api.remarkable_client.EVENT_SUCCESS: self.btn_sign_in.config(state="disabled") self.entry_onetime_code.config(state="disabled") self.btn_create_backup.config(state="normal") self.btn_save.config(state="normal") self.entry_backup_root.config(state="normal") self.entry_backup_folder.config(state="normal") self.entry_templates.config(state="normal") self.label_auth_status.config(text="Successfully signed in", fg="green") elif event == api.remarkable_client.EVENT_USER_TOKEN_FAILED: self.label_auth_status.config( text="Could not renew user token\n(please try again).", fg="red") self.entry_onetime_code.config(state="disabled") elif event == api.remarkable_client.EVENT_ONETIMECODE_NEEDED: self.label_auth_status.config(text="Enter one-time code.", fg="red") else: self.label_auth_status.config(text="Could not sign in.", fg="red") def btn_sign_in_click(self): onetime_code = self.entry_onetime_code_text.get() self.rm_client.sign_in(onetime_code) def btn_save_click(self): general = { "templates": self.entry_templates_text.get(), "backuproot": self.backup_root_text.get() } cfg.save({"general": general}) def btn_create_backup(self): message = "If your explorer is not synchronized, some files are not included in the backup. Should we continue?" result = messagebox.askquestion("Info", message, icon='warning') if result != "yes": return backup_root = self.backup_root_text.get() backup_folder = self.backup_folder_text.get() backup_path = Path.joinpath(Path(backup_root), backup_folder) self.label_backup_progress.config(text="Writing backup '%s'" % backup_path) def run(): self.item_manager.create_backup(backup_path) self.label_backup_progress.config(text="") messagebox.showinfo( "Info", "Successfully created backup '%s'" % backup_path) threading.Thread(target=run).start()
class Item(object): # # CTOR # def __init__(self, metadata, parent=None): self.metadata = metadata self._parent = parent self._children = [] self.path = get_path(self.id()) self.path_remapy = get_path_remapy(self.id()) self.path_metadata_local = get_path_metadata_local(self.id()) self.rm_client = RemarkableClient() self.state_listener = [] # # Getter and setter # def is_trash(self): return self.id() == "trash" def is_root(self): return self.metadata is None def id(self): return self._meta_value("ID") def name(self): return self._meta_value("VissibleName") def version(self): return self._meta_value("Version", -1) def bookmarked(self): return self._meta_value("Bookmarked", False) def is_document(self): return self._meta_value("Type", "CollectionType") == "DocumentType" def is_collection(self): return self._meta_value("Type", "CollectionType") != "DocumentType" def modified_time(self): modified = self.metadata["ModifiedClient"] if modified == None: return None try: utc = datetime.strptime(modified, "%Y-%m-%dT%H:%M:%S.%fZ") except: utc = datetime.strptime(modified, "%Y-%m-%dT%H:%M:%SZ") try: epoch = time.mktime(utc.timetuple()) offset = datetime.fromtimestamp(epoch) - datetime.utcfromtimestamp( epoch) except: print("(Warning) Failed to parse datetime for item %s" % self.id()) return datetime(1970, 1, 1, 0, 0, 0) return utc + offset def parent(self): return self._parent def children(self): return self._children def _meta_value(self, key, root_value=""): if self.is_root(): return root_value return self.metadata[key] # # Functions # def set_bookmarked(self, bookmarked): if self.is_trash() or self.is_root(): return self.metadata["Bookmarked"] = bookmarked self.metadata["ModifiedClient"] = now_rfc3339() self.metadata["Version"] += 1 self.rm_client.update_metadata(self.metadata) self._write_remapy_file() self._update_state_listener() def rename(self, new_name): if self.is_trash() or self.is_root(): return self.metadata["VissibleName"] = new_name self.metadata["ModifiedClient"] = now_rfc3339() self.metadata["Version"] += 1 self.rm_client.update_metadata(self.metadata) self._write_remapy_file() self._update_state_listener() def move(self, new_parent): if self.is_trash() or self.is_root(): return self._parent = new_parent self.metadata["Parent"] = new_parent.id() self.metadata["ModifiedClient"] = now_rfc3339() self.metadata["Version"] += 1 self.rm_client.update_metadata(self.metadata) self._write_remapy_file() self._update_state_listener() def add_state_listener(self, listener): self.state_listener.append(listener) def _update_state_listener(self): for listener in self.state_listener: listener(self) def _write_remapy_file(self): if self.is_root(): return Path(self.path_remapy).mkdir(parents=True, exist_ok=True) with open(self.path_metadata_local, "w") as out: out.write(json.dumps(self.metadata, indent=4))