diff --git a/README.md b/README.md index d1be67b..554f079 100644 --- a/README.md +++ b/README.md @@ -55,19 +55,25 @@ The "Packages" directory is located at: * OS X: ```bash -~/Library/Application Support/Sublime Text/Packages +~/Library/Application Support/Sublime Text/Packages/ +# or +~/Library/Application Support/Sublime Text/Installed Packages/ ``` * Linux: ```bash ~/.config/sublime-text/Packages/ +# or +~/.config/sublime-text/Installed Packages/ ``` * Windows: ```bash %APPDATA%/Sublime Text/Packages/ +# or +%APPDATA%/Sublime Text/Installed Packages/ ``` ## Configuration @@ -107,7 +113,7 @@ After setting them it will download the notes. Hit the shortcut again after the download is done (check the message bar) and it will **show a list of the notes**: ![Alt Notes](assets/images/note_list.png "Note List") -It will download notes every time sublime text is launched and every now and then if the _sync_every_ configuration is enabled (has a positive value), so take a look at the bar to check the status. +It will download notes every time sublime text is launched and every now and then if the _sync_interval_ configuration is enabled (has a positive value), so take a look at the bar to check the status. If a note gets updated from somewhere else ![Alt External Update](http://i.imgur.com/p9pAY6z.png "External Update") diff --git a/Simplenote.sublime-settings b/Simplenote.sublime-settings index 5a49b37..05d6676 100644 --- a/Simplenote.sublime-settings +++ b/Simplenote.sublime-settings @@ -9,8 +9,8 @@ // -------------------------------- // Sync when sublime text starts: ,"autostart": true - // Sync automatically (in seconds) - ,"sync_every": 30 + // Sync automatically interval (in seconds) + ,"sync_interval": 30 // Number of notes synchronized each time ,"sync_note_number": 1000 // Conflict resolution (If a file was edited on another client and also here, on sync..) diff --git a/_config.py b/_config.py index c589586..518f400 100644 --- a/_config.py +++ b/_config.py @@ -40,7 +40,7 @@ // Sync when sublime text starts: ,"autostart": true // Sync automatically (in seconds) - ,"sync_every": 30 + ,"sync_interval": 30 // Number of notes synchronized each time ,"sync_note_number": 1000 // Conflict resolution (If a file was edited on another client and also here, on sync..) @@ -133,8 +133,8 @@ def __init_subclass__(cls, **kwargs): SIMPLENOTE_NOTES_DIR = os.path.join(SIMPLENOTE_CACHE_DIR, "notes") os.makedirs(SIMPLENOTE_NOTES_DIR, exist_ok=True) - # SIMPLENOTE_STARTED: bool = False - # SIMPLENOTE_RELOAD_CALLS: int = -1 + SIMPLENOTE_STARTED_KEY: str = "simplenote_started" + SIMPLENOTE_SYNC_TIMES_KEY: str = "simplenote_sync_times" class Development(_BaseConfig): diff --git a/commands.py b/commands.py index bd19f36..9aef21c 100644 --- a/commands.py +++ b/commands.py @@ -7,8 +7,8 @@ import sublime_plugin from ._config import CONFIG -from .lib.core import start -from .lib.gui import clear_orphaned_filepaths, close_view, on_note_changed, open_view, show_message +from .lib.core import GlobalStorage, sync_once +from .lib.gui import close_view, on_note_changed, open_view, show_message, show_quick_panel from .lib.models import Note from .lib.operations import NoteCreator, NoteDeleter, NotesIndicator, NoteUpdater, OperationManager @@ -25,7 +25,7 @@ logger = logging.getLogger() -SIMPLENOTE_STARTED = False +global_storage = GlobalStorage() class SimplenoteViewCommand(sublime_plugin.EventListener): @@ -34,7 +34,6 @@ class SimplenoteViewCommand(sublime_plugin.EventListener): @cached_property def autosave_debounce_time(self) -> int: - logger.warning(("CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH", CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH)) settings = sublime.load_settings(CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH) _autosave_debounce_time = settings.get("autosave_debounce_time", 1) if not isinstance(_autosave_debounce_time, int): @@ -123,53 +122,11 @@ def on_post_save(self, view: sublime.View): class SimplenoteListCommand(sublime_plugin.ApplicationCommand): - def on_select(self, selected_index: int): - if selected_index == -1: - return - note_id = self.list__modificationDate[selected_index] - selected_note = Note.tree.find(note_id) - if not isinstance(selected_note, Note): - show_message("Note not found: note id(%s), Please restart simplenote or sublime text." % note_id) - return - filepath = selected_note.open() - selected_note.flush() - view = open_view(filepath) - def run(self): - global SIMPLENOTE_STARTED - if not SIMPLENOTE_STARTED: - if not start(): - return - - if Note.tree.count <= 0: - show_message( - "No notes found. Please wait for the synchronization to complete, or press [super+shift+s, super+shift+c] to create a note." - ) - self.list__modificationDate: List[float] = [] - self.list__title: List[str] = [] - list__filename: List[str] = [] - for note in Note.tree.iter(reverse=True): - if not isinstance(note, Note): - raise Exception("note is not a Note: %s" % type(note)) - if note.d.deleted == True: - continue - self.list__modificationDate.append(note.d.modificationDate) - self.list__title.append(note.title) - list__filename.append(note.filename) - - # TODO: Maybe doesn't need to run every time - clear_orphaned_filepaths(list__filename) - - def show_panel(): - sublime.active_window().show_quick_panel( - self.list__title, - self.on_select, - flags=sublime.KEEP_OPEN_ON_FOCUS_LOST, - # on_highlight=self.on_select, - placeholder="Select Note press key 'enter' to open", - ) - - sublime.set_timeout(show_panel, 50) + if Note.tree.count: + show_quick_panel() + if not global_storage.get(CONFIG.SIMPLENOTE_STARTED_KEY): + sync_once() class SimplenoteSyncCommand(sublime_plugin.ApplicationCommand): @@ -179,14 +136,32 @@ def merge_note(self, updated_notes: List[Note]): if note.need_flush: on_note_changed(note) + def callback(self, updated_notes: List[Note]): + self.merge_note(updated_notes) + + sync_times = global_storage.get(CONFIG.SIMPLENOTE_SYNC_TIMES_KEY) + if not isinstance(sync_times, int): + raise TypeError( + "Value of %s must be type %s, got %s" % (CONFIG.SIMPLENOTE_SYNC_TIMES_KEY, int, type(sync_times)) + ) + first_sync = sync_times == 0 + if first_sync: + show_quick_panel(first_sync) + global_storage.optimistic_update(CONFIG.SIMPLENOTE_SYNC_TIMES_KEY, sync_times + 1) + global_storage.optimistic_update(CONFIG.SIMPLENOTE_STARTED_KEY, False) + def run(self): + if global_storage.get(CONFIG.SIMPLENOTE_STARTED_KEY): + return + global_storage.optimistic_update(CONFIG.SIMPLENOTE_STARTED_KEY, True) + settings = sublime.load_settings(CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH) sync_note_number = settings.get("sync_note_number", 1000) if not isinstance(sync_note_number, int): show_message("`sync_note_number` must be an integer. Please check settings file.") return note_indicator = NotesIndicator(sync_note_number=sync_note_number) - note_indicator.set_callback(self.merge_note) + note_indicator.set_callback(self.callback) OperationManager().add_operation(note_indicator) diff --git a/dev_deploy.sh b/dev_deploy.sh new file mode 100644 index 0000000..68b4c76 --- /dev/null +++ b/dev_deploy.sh @@ -0,0 +1,4 @@ +# local development deploy +zip -r Simplenote.sublime-package . -x ".env*" ".git/*" ".github/*" ".gitignore" ".idea/*" ".vscode/*" ".pytest_cache/*" "pyproject.toml" "package-lock.json" "package.json" "node_modules/*" ".env.*" ".DS_Store" "assets/*" "*__pycache__/*" "tmp/*" "tests/*" "logs/*" "sublime_api.py" "dev_deploy.sh" "package-metadata.json" + +mv Simplenote.sublime-package $HOME/Library/Application\ Support/Sublime\ Text/Installed\ Packages/Simplenote.sublime-package diff --git a/lib/core.py b/lib/core.py index 026e918..0188e29 100644 --- a/lib/core.py +++ b/lib/core.py @@ -3,44 +3,63 @@ import sublime from .._config import CONFIG +from ..utils.lock.thread import OptimisticLockingDict +from ..utils.patterns.singleton.base import Singleton from .gui import edit_settings, remove_status, show_message from .operations import OperationManager logger = logging.getLogger() -SIMPLENOTE_STARTED = False -def sync(): - manager = OperationManager() +class GlobalStorage(Singleton, OptimisticLockingDict): + __mapper_key_type = {CONFIG.SIMPLENOTE_SYNC_TIMES_KEY: int, CONFIG.SIMPLENOTE_STARTED_KEY: bool} + + def optimistic_update(self, key, new_value): + _type = self.__mapper_key_type.get(key) + if not _type is None: + if not isinstance(new_value, _type): + raise TypeError("Value of %s must be type %s, got %s" % (key, _type, type(new_value))) + + if key == CONFIG.SIMPLENOTE_SYNC_TIMES_KEY: + import time + + logger.warning((time.time(), key, new_value)) + return super().optimistic_update(key, new_value) + + +manager = OperationManager() +global_storage = GlobalStorage() + + +def sync_once(): if not manager.running: sublime.run_command("simplenote_sync") else: logger.debug("Sync omitted") - settings = sublime.load_settings(CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH) - sync_every = settings.get("sync_every", 0) - logger.debug(("Simplenote sync_every", sync_every)) - if not isinstance(sync_every, int): - show_message("`sync_every` must be an integer. Please check settings file.") - return - if sync_every > 0: - sublime.set_timeout(sync, sync_every * 1000) +def sync(sync_interval: int = 30): + sync_once() + sublime.set_timeout(sync, sync_interval * 1000) def start(): - global SIMPLENOTE_STARTED - settings = sublime.load_settings("Simplenote.sublime-settings") + settings = sublime.load_settings(CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH) username = settings.get("username") password = settings.get("password") if username and password: - sync() - SIMPLENOTE_STARTED = True - else: - edit_settings() - show_message("Simplenote: Please configure username/password, Please check settings file.") - sublime.set_timeout(remove_status, 2000) - SIMPLENOTE_STARTED = False - return SIMPLENOTE_STARTED + if global_storage.get(CONFIG.SIMPLENOTE_SYNC_TIMES_KEY) != 0: + return + sync_interval = settings.get("sync_interval", 30) + if not isinstance(sync_interval, int): + show_message("`sync_interval` must be an integer. Please check settings file.") + return + if sync_interval <= 0: + return + sync(sync_interval) + return + show_message("Simplenote: Please configure username/password in settings file.") + edit_settings() + sublime.set_timeout(remove_status, 2000) diff --git a/lib/gui.py b/lib/gui.py index c33e1ef..b26bd9d 100644 --- a/lib/gui.py +++ b/lib/gui.py @@ -21,6 +21,7 @@ "close_view", "clear_orphaned_filepaths", "on_note_changed", + "show_quick_panel", ] @@ -121,3 +122,52 @@ def on_note_changed(note: Note): old_window.focus_view(old_active_view) sublime.set_timeout(partial(new_view.run_command, "revert"), 0) + + +def on_select(list__modificationDate: List[float], selected_index: int): + if selected_index == -1: + return + note_id = list__modificationDate[selected_index] + selected_note = Note.tree.find(note_id) + if not isinstance(selected_note, Note): + show_message("Note not found: note id(%s), Please restart simplenote or sublime text." % note_id) + return + filepath = selected_note.open() + selected_note.flush() + view = open_view(filepath) + + +def show_quick_panel(first_sync: bool = False): + if Note.tree.count <= 0: + show_message( + "No notes found. Please wait for the synchronization to complete, or press [super+shift+s, super+shift+c] to create a note." + ) + list__modificationDate: List[float] = [] + list__title: List[str] = [] + list__filename: List[str] = [] + for note in Note.tree.iter(reverse=True): + if not isinstance(note, Note): + raise Exception("note is not a Note: %s" % type(note)) + if note.d.deleted == True: + continue + list__modificationDate.append(note.d.modificationDate) + list__title.append(note.title) + list__filename.append(note.filename) + + # TODO: Maybe doesn't need to run every time + clear_orphaned_filepaths(list__filename) + + placeholder = "Select Note press key 'enter' to open" + if first_sync: + placeholder = "Sync complete. Press [super+shift+s] [super+shift+l] to display the note list again." + + def show_panel(): + sublime.active_window().show_quick_panel( + list__title, + partial(on_select, list__modificationDate), + flags=sublime.MONOSPACE_FONT, + # on_highlight=self.on_select, + placeholder=placeholder, + ) + + sublime.set_timeout(show_panel, 500) diff --git a/main.py b/main.py index dc3f65f..7dd06ee 100644 --- a/main.py +++ b/main.py @@ -3,13 +3,17 @@ import sublime from ._config import CONFIG -from .lib.core import start +from .lib.core import GlobalStorage, start +from .lib.gui import show_message from .lib.models import Note logger = logging.getLogger() -SIMPLENOTE_RELOAD_CALLS = -1 + +global_storage = GlobalStorage() +global_storage.optimistic_update(CONFIG.SIMPLENOTE_STARTED_KEY, False) +global_storage.optimistic_update(CONFIG.SIMPLENOTE_SYNC_TIMES_KEY, 0) def reload_if_needed(): @@ -17,29 +21,28 @@ def reload_if_needed(): # # Sublime calls this twice for some reason :( # SIMPLENOTE_RELOAD_CALLS += 1 + # logger.warning((SIMPLENOTE_RELOAD_CALLS, SIMPLENOTE_RELOAD_CALLS % 2)) # if SIMPLENOTE_RELOAD_CALLS % 2 != 0: # logger.debug("Simplenote Reload call %s" % SIMPLENOTE_RELOAD_CALLS) # return - logger.warning(("CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH", CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH)) - settings = sublime.load_settings("Simplenote.sublime-settings") - autostart = settings.get("autostart") - if bool(autostart): - autostart = True - logger.debug(("Simplenote Reloading", autostart)) + settings = sublime.load_settings(CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH) + autostart = settings.get("autostart", True) + if not isinstance(autostart, bool): + show_message("`autostart` must be a boolean. Please check settings file.") + return if autostart: - sublime.set_timeout(start, 2000) - logger.debug("Auto Starting") + start() def plugin_loaded(): # load_notes() logger.debug(("Loaded notes number: ", Note.tree.count)) - logger.warning(("CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH", CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH)) - settings = sublime.load_settings("Simplenote.sublime-settings") + settings = sublime.load_settings(CONFIG.SIMPLENOTE_SETTINGS_FILE_PATH) # logger.debug(("SETTINGS.__dict__: ", SETTINGS.__dict__)) # logger.debug(("SETTINGS.username: ", SETTINGS.get("username"))) + settings.clear_on_change("username") settings.clear_on_change("password") settings.add_on_change("username", reload_if_needed) diff --git a/utils/lock/thread.py b/utils/lock/thread.py new file mode 100644 index 0000000..d853264 --- /dev/null +++ b/utils/lock/thread.py @@ -0,0 +1,181 @@ +__version__ = "0.0.1" +__author__ = "redatman" +__date__ = "2024-08-03" +# TODO: ResultProcess unable to collect results yet + + +from importlib import import_module +import logging +from multiprocessing import Process +from threading import Thread +from typing import Callable + + +import_module("utils.logger.init") +logger = logging.getLogger() + + +class ResultExecutorMixin: + start: Callable + _target: Callable + _args: tuple + _kwargs: dict + _result = None + + def run(self): + try: + if self._target is not None: + self._result = self._target(*self._args, **self._kwargs) + finally: + del self._target, self._args, self._kwargs + + def join(self, *args): + super().join(*args) # type: ignore + # logger.warning(getattr(self, "_result", None)) + return getattr(self, "_result", None) + + def get_result(self): + self.start() + return self.join() + + +ResultProcess = type("ResultProcess", (ResultExecutorMixin, Process), {}) +ResultThread = type("ResultThread", (ResultExecutorMixin, Thread), {}) + + +class OptimisticLockingError(Exception): + def __init__(self, key: str) -> None: + super().__init__(f"Update failed due to concurrent modification: {key}") + + +class OptimisticLockingDict: + + def __init__(self, executor_cls=ResultThread): + if issubclass(executor_cls, Process): + from multiprocessing import Lock, Manager + + self.data = Manager().dict() + self.lock = Lock() + elif issubclass(executor_cls, Thread): + from threading import Lock + + self.data = {} + self.lock = Lock() + else: + raise ValueError( + f"Unsupported executor class: {executor_cls}, must be either multiprocessing.Process or threading.Thread" + ) + + def _get(self, key): + + # logger.debug(("_get", os.getpid(), threading.current_thread().name, threading.current_thread().ident)) + with self.lock: + if key in self.data: + value, version = self.data[key] + return value, version + else: + return None, None + + def get(self, key): + logger.info(self.data) + value, version = self._get(key) + return value + + def _set(self, key, new_value, expected_version): + # logger.warning((id(self.data), self.data)) + # logger.debug(("_set", os.getpid(), threading.current_thread().name, threading.current_thread().ident)) + with self.lock: + if key in self.data: + current_value, current_version = self.data[key] + if current_version == expected_version: + self.data[key] = (new_value, current_version + 1) + return True + else: + return False + else: + # If the key does not exist, initialize it + self.data[key] = (new_value, 1) + return True + + def set(self, key, new_value): + return self._set(key, new_value, 0) + + def optimistic_update(self, key, new_value): + # logger.warning((id(self), id(self.data))) + # logger.warning((id(self), self)) + # logger.debug(f">>: {key} = {new_value}") + value, version = self._get(key) + # time.sleep(0.1) + if value is not None: + success = self._set(key, new_value, version) + if success: + logger.debug(f"Update successful: {key} from {value} to {new_value}") + else: + logger.debug(f"Update failed due to concurrent modification: {key} to {new_value}") + raise OptimisticLockingError(key) + else: + # Initialize the key if it doesn't exist + self.set(key, new_value) + logger.debug(f"Initial set: {key} = {new_value}") + return new_value + + # def update(self, key, new_value): + # with self.lock: + # return self.optimistic_update(key, new_value) + + +def test_multiple_updates(executor_cls): + optimistic_dict = OptimisticLockingDict(executor_cls) + logger.warning((id(optimistic_dict), id(optimistic_dict.data))) + key = "name" + + # Initialize a key-value pair + optimistic_dict.optimistic_update(key, "value1") + + # tasks = [] + results = set() + + # Simulate concurrent updates + def concurrent_update(): + for i in range(6): + task = executor_cls(target=optimistic_dict.optimistic_update, args=("name", i)) + import time + + # time.sleep(0.01) + # tasks.append(task) + # task.start() + # result = task.join() + result = task.get_result() + logger.debug(result) + results.add(result) + + logger.info(results) + + concurrent_update() + last_result = optimistic_dict.get(key) + expected_result = 5 + assert last_result == expected_result, f"Expected last value is {expected_result}, but got %s" % last_result + expected_results = {0, 1, 2, 3, 4, 5} + assert results == expected_results, f"Expected results is {expected_results}, but got {results}" + + +def run_tests(): + tests = { + ("Test test_multiple_process_updates ", test_multiple_updates, (ResultProcess,)), + ("Test test_multiple_thread_updates ", test_multiple_updates, (ResultThread,)), + } + + for test_name, test, args in tests: + try: + prefix = f"Running [{test_name}]" + test(*args) + logger.info(f"{prefix} Succeeded") + except AssertionError as e: + logger.error(f"{prefix} Failed => {e}") + except Exception as e: + logger.critical(f"{prefix} Exception => {e}") + + +if __name__ == "__main__": + + run_tests() diff --git a/utils/logger/init.py b/utils/logger/init.py index d91881b..be59ad1 100644 --- a/utils/logger/init.py +++ b/utils/logger/init.py @@ -188,10 +188,10 @@ "": { "handlers": [ # "default", - "info", - "warning", - "error", - "critical", + # "info", + # "warning", + # "error", + # "critical", # Keep console at the end. for colored output only at stdout. "console", ],