diff --git a/README.md b/README.md index 3e1e65d..05b27d2 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Download, browse and delete models in ComfyUI. Model Manager Demo Screenshot - View model metadata and file info. -- Read, edit and save notes in a `.txt` file beside the model. +- Read, edit and save notes in a `.txt` file beside the model. (Autosave optional.) - Change or remove a model's preview image (add a different one using a url or local upload). - Rename, move or **permanently** remove model files. diff --git a/__init__.py b/__init__.py index 34bd6ee..3aca269 100644 --- a/__init__.py +++ b/__init__.py @@ -182,6 +182,7 @@ def ui_rules(): Rule("model-real-time-search", True, bool), Rule("model-persistent-search", True, bool), Rule("model-show-label-extensions", False, bool), + Rule("model-info-autosave-notes", False, bool), Rule("model-preview-fallback-search-safetensors-thumbnail", False, bool), Rule("model-show-add-button", True, bool), Rule("model-show-copy-button", True, bool), @@ -228,6 +229,11 @@ def get_def_headers(url=""): return def_headers +@server.PromptServer.instance.routes.get("/model-manager/timestamp") +async def get_timestamp(request): + return web.json_response({ "timestamp": datetime.now().timestamp() }) + + @server.PromptServer.instance.routes.get("/model-manager/settings/load") async def load_ui_settings(request): rules = ui_rules() @@ -1166,6 +1172,8 @@ async def set_notes(request): body = await request.json() result = { "success": False } + dt_epoch = body.get("timestamp", None) + text = body.get("notes", None) if type(text) is not str: result["alert"] = "Invalid note!" @@ -1179,15 +1187,23 @@ async def set_notes(request): model_extensions = folder_paths_get_supported_pt_extensions(model_type) file_path_without_extension, _ = split_valid_ext(model_path, model_extensions) filename = os.path.normpath(file_path_without_extension + model_info_extension) + + if dt_epoch is not None and os.path.exists(filename) and os.path.getmtime(filename) > dt_epoch: + # discard late save + result["success"] = True + return web.json_response(result) + if text.isspace() or text == "": if os.path.exists(filename): os.remove(filename) - print("Deleted file: " + filename) + #print("Deleted file: " + filename) # autosave -> too verbose else: try: with open(filename, "w", encoding="utf-8") as f: f.write(text) - print("Saved file: " + filename) + if dt_epoch is not None: + os.utime(filename, (dt_epoch, dt_epoch)) + #print("Saved file: " + filename) # autosave -> too verbose except ValueError as e: print(e, file=sys.stderr, flush=True) result["alert"] = "Failed to save notes!\n\n" + str(e) diff --git a/web/model-manager.js b/web/model-manager.js index 4976220..7147508 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -17,6 +17,21 @@ function request(url, options = undefined) { }); } +/** + * @param {(...args) => Promise} callback + * @param {number | undefined} delay + * @returns {(...args) => void} + */ +function debounce(callback, delay) { + let timeoutId = null; + return (...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + callback(...args); + }, delay); + }; +} + /** * @param {string} url */ @@ -153,21 +168,6 @@ const PREVIEW_NONE_URI = imageUri(); const PREVIEW_THUMBNAIL_WIDTH = 320; const PREVIEW_THUMBNAIL_HEIGHT = 480; -/** - * @param {(...args) => void} callback - * @param {number | undefined} delay - * @returns {(...args) => void} - */ -function debounce(callback, delay) { - let timeoutId = null; - return (...args) => { - window.clearTimeout(timeoutId); - timeoutId = window.setTimeout(() => { - callback(...args); - }, delay); - }; -} - /** * * @param {HTMLButtonElement} element @@ -248,6 +248,11 @@ function comfyButtonAlert(element, success, successClassName = undefined, failur * @returns {Promise} */ async function saveNotes(modelPath, newValue) { + const timestamp = await request("/model-manager/timestamp") + .catch((err) => { + console.warn(err); + return false; + }); return await request( "/model-manager/notes/save", { @@ -256,6 +261,7 @@ async function saveNotes(modelPath, newValue) { "path": modelPath, "notes": newValue, }), + timestamp: timestamp, } ).then((result) => { const saved = result["success"]; @@ -2201,7 +2207,7 @@ class ModelInfo { } /** - * @param {boolean} + * @param {boolean} promptUser * @returns {Promise} */ async trySave(promptUser) { @@ -2567,6 +2573,33 @@ class ModelInfo { const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value tagButton.style.display = isTags ? "" : "none"; + const saveIcon = "content-save"; + const savingIcon = "cloud-upload-outline"; + + const saveNotesButton = new ComfyButton({ + icon: saveIcon, + tooltip: "Save note", + classList: "comfyui-button icon-button", + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const saved = await this.trySave(false); + comfyButtonAlert(e.target, saved); + button.disabled = false; + }, + }).element; + + const saveDebounce = debounce(async() => { + const saveIconClass = "mdi-" + saveIcon; + const savingIconClass = "mdi-" + savingIcon; + const iconElement = saveNotesButton.getElementsByTagName("i")[0]; + iconElement.classList.remove(saveIconClass); + iconElement.classList.add(savingIconClass); + const saved = await this.trySave(false); + iconElement.classList.remove(savingIconClass); + iconElement.classList.add(saveIconClass); + }, 2000); + /** @type {HTMLDivElement} */ const notesElement = this.elements.tabContents[3]; // TODO: remove magic value notesElement.innerHTML = ""; @@ -2575,6 +2608,11 @@ class ModelInfo { const notes = $el("textarea.comfy-multiline-input", { name: "model notes", value: noteText, + oninput: (e) => { + if (this.#settingsElements["model-info-autosave-notes"].checked) { + saveDebounce(); + } + }, }); this.elements.notes = notes; this.#savedNotesValue = noteText; @@ -2583,18 +2621,7 @@ class ModelInfo { style: { "align-items": "center" }, }, [ $el("h1", ["Notes"]), - new ComfyButton({ - icon: "content-save", - tooltip: "Save note", - classList: "comfyui-button icon-button", - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const saved = await this.trySave(false); - comfyButtonAlert(e.target, saved); - button.disabled = false; - }, - }).element, + saveNotesButton, ]), $el("div", { style: { "display": "flex", "height": "100%", "min-height": "60px" }, @@ -3614,6 +3641,7 @@ class SettingsView { /** @type {HTMLInputElement} */ "model-real-time-search": null, /** @type {HTMLInputElement} */ "model-persistent-search": null, /** @type {HTMLInputElement} */ "model-show-label-extensions": null, + /** @type {HTMLInputElement} */ "model-info-autosave-notes": null, /** @type {HTMLInputElement} */ "model-preview-fallback-search-safetensors-thumbnail": null, /** @type {HTMLInputElement} */ "model-show-add-button": null, @@ -3819,6 +3847,10 @@ class SettingsView { $: (el) => (settings["model-show-label-extensions"] = el), textContent: "Show model file extension in labels", }), + $checkbox({ + $: (el) => (settings["model-info-autosave-notes"] = el), + textContent: "Autosave notes", + }), $checkbox({ $: (el) => (settings["model-preview-fallback-search-safetensors-thumbnail"] = el), textContent: "Fallback on embedded thumbnail in safetensors (refresh slow)",