Save Current State Beginning Upgrade
This commit is contained in:
+3
-140
@@ -1,142 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from .nodes.zmongo_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
from src.zmongo_toolbag.local_vector_search import LocalVectorSearch
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
from src.zmongo_toolbag.zembedder import ZEmbedder
|
||||
|
||||
from src.zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
from src.zmongo_toolbag.safe_result import SafeResult
|
||||
|
||||
from src.zmongo_toolbag.zmongo import ZMongo
|
||||
|
||||
# Ensure the custom node package directory is importable so `.src...` works
|
||||
NODE_PATH = os.path.dirname(__file__)
|
||||
if NODE_PATH not in sys.path:
|
||||
sys.path.append(NODE_PATH)
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
from src.zmongo_nodes import (
|
||||
ZMongoConfigNode,
|
||||
ZMongoDataPassThroughNode,
|
||||
ZMongoDatabaseBrowserNode,
|
||||
ZMongoFieldSelector,
|
||||
ZMongoOperationsNode,
|
||||
ZMongoRecordLoopNode,
|
||||
ZMongoRecordSplitter,
|
||||
ZMongoSaveBatchTextNode,
|
||||
ZMongoSaveTextNode,
|
||||
ZMongoSaveValueNode,
|
||||
ZMongoTextFetcher,
|
||||
ZRetrieverNode,
|
||||
)
|
||||
from src.zmongo_field_selector_node import ZMongoFieldSelectorNode
|
||||
from src.zmongo_flattened_field_selector_node import ZMongoFlattenedFieldSelectorNode
|
||||
from src.zmongo_tabular_record_view_node import ZMongoTabularRecordViewNode
|
||||
|
||||
from src.zmongo_record_editor_node import ZMongoRecordEditorNode
|
||||
|
||||
|
||||
from src.model_loader import (
|
||||
ZMongoModelCompatibilityDisplayNode,
|
||||
ZMongoModelIntrospectorNode,
|
||||
ZMongoUniversalModelSelectorNode,
|
||||
ZMongoUniversalModelLoaderNode,
|
||||
ZMongoUniversalModelAdapterNode,
|
||||
ZMongoBuiltInLoaderAdapterNode,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
###############################################################################
|
||||
# ComfyUI Required API Registration
|
||||
###############################################################################
|
||||
#
|
||||
# def _safe_register(route_func, label: str) -> None:
|
||||
# """Register PromptServer routes without breaking node loading."""
|
||||
# try:
|
||||
# route_func(PromptServer.instance)
|
||||
# except Exception as exc:
|
||||
# logger.warning("ZMongo %s route registration warning: %s", label, exc)
|
||||
#
|
||||
# from .src.zmongo_record_editor_api import register_zmongo_record_editor_routes
|
||||
# from .src.preset_api import register_preset_routes
|
||||
# from .src.zmongo_tabular_record_view_api import (
|
||||
# register_zmongo_tabular_record_view_routes,
|
||||
# )
|
||||
# from .src.zmongo_field_selector_api import register_zmongo_field_selector_routes
|
||||
# from server import PromptServer
|
||||
#
|
||||
# _safe_register(register_preset_routes, "preset")
|
||||
# _safe_register(register_zmongo_field_selector_routes, "field selector")
|
||||
# _safe_register(register_zmongo_tabular_record_view_routes, "tabular record view")
|
||||
# _safe_register(register_zmongo_record_editor_routes, "record editor")
|
||||
###############################################################################
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZMongoConfig": ZMongoConfigNode,
|
||||
"ZMongoTextFetcher": ZMongoTextFetcher,
|
||||
"ZMongoOperations": ZMongoOperationsNode,
|
||||
"ZMongoRecordSplitter": ZMongoRecordSplitter,
|
||||
"ZMongoFieldSelector": ZMongoFieldSelector,
|
||||
"ZRetriever": ZRetrieverNode,
|
||||
"ZMongoDatabaseBrowser": ZMongoDatabaseBrowserNode,
|
||||
"ZMongoRecordLoopNode": ZMongoRecordLoopNode,
|
||||
"ZMongoSaveTextNode": ZMongoSaveTextNode,
|
||||
"ZMongoDataPassThroughNode": ZMongoDataPassThroughNode,
|
||||
"ZMongoSaveBatchTextNode": ZMongoSaveBatchTextNode,
|
||||
"ZMongoSaveValueNode": ZMongoSaveValueNode,
|
||||
"ZMongoFlattenedFieldSelectorNode": ZMongoFlattenedFieldSelectorNode,
|
||||
"ZMongoFieldSelectorNode": ZMongoFieldSelectorNode,
|
||||
"ZMongoTabularRecordViewNode": ZMongoTabularRecordViewNode,
|
||||
"ZMongoRecordEditorNode": ZMongoRecordEditorNode,
|
||||
"ZMongoUniversalModelSelectorNode": ZMongoUniversalModelSelectorNode,
|
||||
"ZMongoModelIntrospectorNode": ZMongoModelIntrospectorNode,
|
||||
"ZMongoModelCompatibilityDisplayNode": ZMongoModelCompatibilityDisplayNode,
|
||||
"ZMongoUniversalModelLoaderNode": ZMongoUniversalModelLoaderNode,
|
||||
"ZMongoUniversalModelAdapterNode": ZMongoUniversalModelAdapterNode,
|
||||
"ZMongoBuiltInLoaderAdapterNode": ZMongoBuiltInLoaderAdapterNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZMongoConfig": "🛡️ ZMongo Configuration",
|
||||
"ZMongoTextFetcher": "📝 ZMongo Text Fetcher",
|
||||
"ZMongoOperations": "📥 ZMongo Operations",
|
||||
"ZMongoRecordSplitter": "✂️ ZMongo Record Splitter",
|
||||
"ZMongoFieldSelector": "🔍 ZMongo Field Selector",
|
||||
"ZRetriever": "🧠 ZMongo Vector Retriever",
|
||||
"ZMongoDatabaseBrowser": "🗂️ ZMongo Database Browser",
|
||||
"ZMongoRecordLoopNode": "🔁 ZMongo Record Loop Node",
|
||||
"ZMongoSaveTextNode": "💾 ZMongo Save Text Node",
|
||||
"ZMongoDataPassThroughNode": "🔀 ZMongo Data Pass Through Node",
|
||||
"ZMongoSaveBatchTextNode": "📚 ZMongo Save Batch Text Node",
|
||||
"ZMongoSaveValueNode": "🧷 ZMongo Save Value Node",
|
||||
"ZMongoFlattenedFieldSelectorNode": "🧭 ZMongo Flattened Field Selector",
|
||||
"ZMongoFieldSelectorNode": "🔎 ZMongo Field Selector Node",
|
||||
"ZMongoTabularRecordViewNode": "📊 ZMongo Tabular Record View",
|
||||
"ZMongoRecordEditorNode": "📝 ZMongo Record Editor",
|
||||
"ZMongoUniversalModelSelectorNode": "🧩 ZMongo Universal Model Selector",
|
||||
"ZMongoModelIntrospectorNode": "🧠 ZMongo Model Introspector",
|
||||
"ZMongoModelCompatibilityDisplayNode": "✅ ZMongo Model Compatibility Display",
|
||||
"ZMongoUniversalModelAdapterNode": " ZMongo Universal Model Adapter",
|
||||
"ZMongoBuiltInLoaderAdapterNode": " ZMongo BuiltIn Loader Adapter",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
"WEB_DIRECTORY",
|
||||
"DataProcessor",
|
||||
"SafeResult",
|
||||
"ZMongo",
|
||||
"ZEmbedder",
|
||||
"LocalVectorSearch",
|
||||
]
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
|
||||
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
# 1. PATH CONFIGURATION 🛠️
|
||||
# This allows internal files to find 'zmongo_toolbag' and 'gui' easily.
|
||||
ROOT_PATH = os.path.dirname(__file__)
|
||||
if ROOT_PATH not in sys.path:
|
||||
sys.path.append(ROOT_PATH)
|
||||
|
||||
# 2. WEB ASSET REGISTRATION 🌐
|
||||
# Tells ComfyUI where to find your JavaScript files for the custom UI.
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 3. NODE & API IMPORTS 📦
|
||||
# We import from our sub-packages. These rely on the __init__.py files
|
||||
# you have inside the 'nodes' and 'gui' folders.
|
||||
from .nodes import (
|
||||
NODE_CLASS_MAPPINGS as BASE_NODES,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as BASE_DISPLAY,
|
||||
)
|
||||
|
||||
# register_zmongo_field_selector_routes,
|
||||
# register_zmongo_record_editor_routes,
|
||||
# register_zmongo_tabular_record_view_routes,
|
||||
from server.zmongo_field_selector_api import register_zmongo_field_selector_routes
|
||||
from server.zmongo_record_editor_api import register_zmongo_record_editor_routes
|
||||
from server.zmongo_tabular_record_view_api import register_zmongo_tabular_record_view_routes
|
||||
|
||||
# 4. SERVER API REGISTRATION 🚦
|
||||
# This connects your Python logic to the ComfyUI web server.
|
||||
try:
|
||||
from .server import PromptServer
|
||||
register_zmongo_field_selector_routes(PromptServer.instance)
|
||||
register_zmongo_record_editor_routes(PromptServer.instance)
|
||||
register_zmongo_tabular_record_view_routes(PromptServer.instance)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register ZMongo API routes: {e}")
|
||||
|
||||
# 5. COMFYUI EXPORTS 📤
|
||||
# These are the two variables ComfyUI looks for to load your nodes.
|
||||
NODE_CLASS_MAPPINGS = {**BASE_NODES}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {**BASE_DISPLAY}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
@@ -0,0 +1,23 @@
|
||||
# 1. ROUTE REGISTRATION IMPORTS 🌐
|
||||
# We use absolute imports to ensure these work correctly during standalone testing.
|
||||
from ComfyUI.custom_nodes..api.zmongo_field_selector_api import register_zmongo_field_selector_routes
|
||||
from api.zmongo_record_editor_api import register_zmongo_record_editor_routes
|
||||
from api.zmongo_tabular_record_view_api import register_zmongo_tabular_record_view_routes
|
||||
|
||||
# 2. BUNDLED REGISTRATION HELPER 🚦
|
||||
# This single function can be called in your root __init__.py to set up everything at once.
|
||||
def register_all_zmongo_routes(prompt_server_instance):
|
||||
"""
|
||||
Registers all ZMongo-related API routes with the ComfyUI server.
|
||||
"""
|
||||
register_zmongo_field_selector_routes(prompt_server_instance)
|
||||
register_zmongo_record_editor_routes(prompt_server_instance)
|
||||
register_zmongo_tabular_record_view_routes(prompt_server_instance)
|
||||
|
||||
# 3. PUBLIC EXPORTS 📤
|
||||
__all__ = [
|
||||
"register_all_zmongo_routes",
|
||||
"register_zmongo_field_selector_routes",
|
||||
"register_zmongo_record_editor_routes",
|
||||
"register_zmongo_tabular_record_view_routes",
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
import logging
|
||||
from aiohttp import web
|
||||
|
||||
from zmongo_field_selector_node import ZMongoFieldSelectorNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_zmongo_field_selector_routes(prompt_server_instance):
|
||||
if not prompt_server_instance:
|
||||
logger.warning("PromptServer instance not available; ZMongo routes not registered.")
|
||||
return
|
||||
|
||||
routes = prompt_server_instance.routes
|
||||
|
||||
@routes.get("/zmongo/flattened_fields")
|
||||
async def zmongo_get_flattened_fields(request):
|
||||
try:
|
||||
collection_name = request.rel_url.query.get("collection_name", "").strip()
|
||||
fields = ZMongoFieldSelectorNode.get_flattened_field_names(collection_name)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"collection_name": collection_name,
|
||||
"fields": fields,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to get flattened fields: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"fields": [],
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
@@ -0,0 +1,224 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from aiohttp import web
|
||||
from bson import ObjectId
|
||||
|
||||
from .zmongo_record_editor_node import ZMongoRecordEditorNode
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_zmongo_record_editor_routes(prompt_server_instance):
|
||||
if not prompt_server_instance:
|
||||
logger.warning("PromptServer instance not available; record editor routes not registered.")
|
||||
return
|
||||
|
||||
routes = prompt_server_instance.routes
|
||||
|
||||
def _clean_record_id(value: Any) -> str:
|
||||
raw = "" if value is None else str(value)
|
||||
cleaned = raw.strip()
|
||||
|
||||
if len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in {"'", '"'}:
|
||||
cleaned = cleaned[1:-1].strip()
|
||||
|
||||
return cleaned
|
||||
|
||||
def _build_id_candidates(value: Any) -> List[Any]:
|
||||
cleaned = _clean_record_id(value)
|
||||
if not cleaned:
|
||||
return []
|
||||
|
||||
candidates: List[Any] = [cleaned]
|
||||
if ObjectId.is_valid(cleaned):
|
||||
oid = ObjectId(cleaned)
|
||||
candidates.insert(0, oid)
|
||||
|
||||
return candidates
|
||||
|
||||
def _parse_json_or_string(value: Any) -> Any:
|
||||
raw = "" if value is None else str(value)
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
return ""
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except Exception:
|
||||
return raw
|
||||
|
||||
def _result_succeeded(result: Any) -> bool:
|
||||
return bool(result) and bool(getattr(result, "success", False))
|
||||
|
||||
def _result_data(result: Any) -> Dict[str, Any]:
|
||||
data = getattr(result, "data", {}) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
@routes.get("/zmongo/record_editor/load")
|
||||
async def zmongo_record_editor_load(request):
|
||||
try:
|
||||
collection_name = request.rel_url.query.get("collection_name", "").strip()
|
||||
record_id = _clean_record_id(request.rel_url.query.get("record_id", ""))
|
||||
selected_record_json = request.rel_url.query.get("selected_record_json", "")
|
||||
|
||||
record = ZMongoRecordEditorNode.fetch_record(
|
||||
collection_name=collection_name,
|
||||
record_id=record_id,
|
||||
fallback_record_json=selected_record_json,
|
||||
)
|
||||
|
||||
if not record:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"collection_name": collection_name,
|
||||
"record_id": record_id,
|
||||
"record": {},
|
||||
"fields": [],
|
||||
}
|
||||
)
|
||||
|
||||
resolved_record_id = str(record.get("_id", record_id))
|
||||
|
||||
pairs = ZMongoRecordEditorNode.get_flattened_record_pairs(
|
||||
collection_name=collection_name,
|
||||
record_id=resolved_record_id,
|
||||
fallback_record_json=selected_record_json,
|
||||
)
|
||||
|
||||
fields = [{"path": path, "value": value} for path, value in pairs]
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"collection_name": collection_name,
|
||||
"record_id": resolved_record_id,
|
||||
"record": DataProcessor.to_json_compatible(record),
|
||||
"fields": fields,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to load record editor payload: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"record": {},
|
||||
"fields": [],
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@routes.post("/zmongo/record_editor/save")
|
||||
async def zmongo_record_editor_save(request):
|
||||
try:
|
||||
payload: Dict[str, Any] = await request.json()
|
||||
|
||||
collection_name = str(payload.get("collection_name", "")).strip()
|
||||
record_id = _clean_record_id(payload.get("record_id", ""))
|
||||
changes = payload.get("changes", {})
|
||||
|
||||
if not collection_name:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "collection_name is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if not record_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "record_id is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if not isinstance(changes, dict) or not changes:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "changes must be a non-empty object"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
normalized_changes = {
|
||||
str(path).strip(): _parse_json_or_string(value)
|
||||
for path, value in changes.items()
|
||||
if str(path).strip()
|
||||
}
|
||||
|
||||
if not normalized_changes:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "No valid change paths were provided"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
zmongo = ZMongo()
|
||||
update_doc = {"$set": normalized_changes}
|
||||
|
||||
result = None
|
||||
matched_any_candidate = False
|
||||
|
||||
for candidate_id in _build_id_candidates(record_id):
|
||||
result = zmongo.update_one(
|
||||
collection_name,
|
||||
{"_id": candidate_id},
|
||||
update_doc,
|
||||
upsert=False,
|
||||
)
|
||||
|
||||
if _result_succeeded(result):
|
||||
data = _result_data(result)
|
||||
matched_count = int(data.get("matched_count", 0) or 0)
|
||||
modified_count = int(data.get("modified_count", 0) or 0)
|
||||
upserted_id = data.get("upserted_id")
|
||||
|
||||
if matched_count > 0:
|
||||
matched_any_candidate = True
|
||||
|
||||
if modified_count > 0 or upserted_id is not None or matched_count > 0:
|
||||
break
|
||||
|
||||
if result is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "record_id is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
refreshed = ZMongoRecordEditorNode.fetch_record(
|
||||
collection_name=collection_name,
|
||||
record_id=record_id,
|
||||
fallback_record_json="",
|
||||
)
|
||||
|
||||
if not matched_any_candidate and not refreshed:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"No record found for _id '{record_id}' in collection '{collection_name}'",
|
||||
"result": getattr(result, "to_dict", lambda: {})(),
|
||||
"collection_name": collection_name,
|
||||
"record_id": record_id,
|
||||
"record": {},
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": bool(getattr(result, "success", False)),
|
||||
"message": getattr(result, "message", "Saved"),
|
||||
"error": getattr(result, "error", None),
|
||||
"result": getattr(result, "to_dict", lambda: {})(),
|
||||
"collection_name": collection_name,
|
||||
"record_id": record_id,
|
||||
"record": DataProcessor.to_json_compatible(refreshed),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to save record editor changes: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
import logging
|
||||
from aiohttp import web
|
||||
|
||||
from .zmongo_tabular_record_view_node import ZMongoTabularRecordViewNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_zmongo_tabular_record_view_routes(prompt_server_instance):
|
||||
if not prompt_server_instance:
|
||||
logger.warning("PromptServer instance not available; tabular record routes not registered.")
|
||||
return
|
||||
|
||||
routes = prompt_server_instance.routes
|
||||
|
||||
@routes.get("/zmongo/tabular_records")
|
||||
async def zmongo_get_tabular_records(request):
|
||||
try:
|
||||
collection_name = request.rel_url.query.get("collection_name", "").strip()
|
||||
|
||||
headings, flat_records, record_ids = ZMongoTabularRecordViewNode.get_table_payload(collection_name)
|
||||
default_record_id = record_ids[0] if record_ids else ""
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"collection_name": collection_name,
|
||||
"headings": headings,
|
||||
"flat_records": flat_records,
|
||||
"record_ids": record_ids,
|
||||
"default_record_id": default_record_id,
|
||||
"record_count": len(flat_records),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to get tabular records: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"headings": [],
|
||||
"flat_records": [],
|
||||
"record_ids": [],
|
||||
"default_record_id": "",
|
||||
"record_count": 0,
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@routes.get("/zmongo/tabular_records_search")
|
||||
async def zmongo_get_tabular_records_search(request):
|
||||
try:
|
||||
collection_name = request.rel_url.query.get("collection_name", "").strip()
|
||||
search_text = request.rel_url.query.get("search_text", "")
|
||||
flattened_field_name = request.rel_url.query.get("flattened_field_name", "").strip()
|
||||
|
||||
headings, flat_records, record_ids = (
|
||||
ZMongoTabularRecordViewNode.get_filtered_table_payload(
|
||||
collection_name=collection_name,
|
||||
search_text=search_text,
|
||||
flattened_field_name=flattened_field_name,
|
||||
)
|
||||
)
|
||||
|
||||
default_record_id = record_ids[0] if record_ids else ""
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"collection_name": collection_name,
|
||||
"search_text": search_text,
|
||||
"flattened_field_name": flattened_field_name,
|
||||
"headings": headings,
|
||||
"flat_records": flat_records,
|
||||
"record_ids": record_ids,
|
||||
"default_record_id": default_record_id,
|
||||
"record_count": len(flat_records),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to search tabular records: %s", exc)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"headings": [],
|
||||
"flat_records": [],
|
||||
"record_ids": [],
|
||||
"default_record_id": "",
|
||||
"record_count": 0,
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ZMongoRecordLoopNode",
|
||||
"pos": [-1200, 200],
|
||||
"size": [340, 420],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "record_data", "type": "STRING", "links": [] },
|
||||
{ "name": "selected_field_text", "type": "STRING", "links": [1] },
|
||||
{ "name": "current_index", "type": "INT", "links": [] },
|
||||
{ "name": "total_count", "type": "INT", "links": [] },
|
||||
{ "name": "emitted_count", "type": "INT", "links": [] },
|
||||
{ "name": "summary", "type": "STRING", "links": [8] },
|
||||
{ "name": "loop_state_json", "type": "STRING", "links": [] }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"mongodb://127.0.0.1:27017",
|
||||
"test",
|
||||
"ocr_docs",
|
||||
"range",
|
||||
1,
|
||||
10,
|
||||
1,
|
||||
1,
|
||||
"_id",
|
||||
"ascending",
|
||||
"text",
|
||||
"text\ndoc_text\ndocument.text\nprompt",
|
||||
"{}",
|
||||
"{}",
|
||||
false,
|
||||
0,
|
||||
"fixed",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
500,
|
||||
"use_placeholder",
|
||||
"prompt_text",
|
||||
"wrap",
|
||||
"record_json",
|
||||
0
|
||||
],
|
||||
"_meta": {
|
||||
"title": "🔁 ZMongo Record Loop"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [-700, 200],
|
||||
"size": [300, 120],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [3] },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": [1, 2] },
|
||||
{ "name": "VAE", "type": "VAE", "links": [6] }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"v1-5-pruned-emaonly-fp16.safetensors"
|
||||
],
|
||||
"_meta": {
|
||||
"title": "SD1.5 Checkpoint"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [-400, 100],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "text", "type": "STRING", "link": 1 },
|
||||
{ "name": "clip", "type": "CLIP", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "CONDITIONING", "type": "CONDITIONING", "links": [4] }
|
||||
],
|
||||
"properties": {},
|
||||
"_meta": {
|
||||
"title": "Positive Prompt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [-400, 300],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "text", "type": "STRING", "widget": { "name": "text" }, "link": null },
|
||||
{ "name": "clip", "type": "CLIP", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "CONDITIONING", "type": "CONDITIONING", "links": [5] }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"bad anatomy, blurry, distorted, extra limbs"
|
||||
],
|
||||
"_meta": {
|
||||
"title": "Negative Prompt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [-100, 200],
|
||||
"size": [300, 120],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "LATENT", "type": "LATENT", "links": [6] }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
512,
|
||||
512,
|
||||
1
|
||||
],
|
||||
"_meta": {
|
||||
"title": "Empty Latent Image"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "KSampler",
|
||||
"pos": [200, 200],
|
||||
"size": [350, 250],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": 3 },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": 5 },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": 6 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "LATENT", "type": "LATENT", "links": [7] }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
1,
|
||||
20,
|
||||
7,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
],
|
||||
"_meta": {
|
||||
"title": "KSampler"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "VAEDecode",
|
||||
"pos": [550, 200],
|
||||
"size": [300, 120],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "samples", "type": "LATENT", "link": 7 },
|
||||
{ "name": "vae", "type": "VAE", "link": 6 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [9] }
|
||||
],
|
||||
"properties": {},
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "SaveImage",
|
||||
"pos": [850, 200],
|
||||
"size": [250, 120],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "images", "type": "IMAGE", "link": 9 }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"zmongo_loop_output"
|
||||
],
|
||||
"_meta": {
|
||||
"title": "Save Image"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "PreviewAny",
|
||||
"pos": [-800, 650],
|
||||
"size": [500, 220],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "source", "type": "*", "link": 8 }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"_meta": {
|
||||
"title": "Loop Summary"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 1, 3, 0, "STRING"],
|
||||
[2, 2, 1, 3, 1, "CLIP"],
|
||||
[3, 2, 0, 6, 0, "MODEL"],
|
||||
[4, 3, 0, 6, 1, "CONDITIONING"],
|
||||
[5, 4, 0, 6, 2, "CONDITIONING"],
|
||||
[6, 5, 0, 6, 3, "LATENT"],
|
||||
[7, 6, 0, 7, 0, "LATENT"],
|
||||
[8, 1, 5, 9, 0, "STRING"],
|
||||
[9, 7, 0, 8, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"_meta": {
|
||||
"title": "🔁 ZMongo → SD1.5 Loop Demo"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ZMongoRecordLoopNode",
|
||||
"pos": [-520, 180],
|
||||
"size": [380, 460],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "selected_record_json_or_list", "type": "STRING", "links": [] },
|
||||
{ "name": "selected_field_value_or_list", "type": "STRING", "links": [1] },
|
||||
{ "name": "current_record_index", "type": "INT", "links": [2] },
|
||||
{ "name": "total_record_count", "type": "INT", "links": [] },
|
||||
{ "name": "returned_item_count", "type": "INT", "links": [] },
|
||||
{ "name": "summary", "type": "STRING", "links": [] },
|
||||
{ "name": "selection_state_json", "type": "STRING", "links": [] }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"mongodb://127.0.0.1:27017",
|
||||
"test",
|
||||
"ocr_docs",
|
||||
"record range",
|
||||
2,
|
||||
1000,
|
||||
1,
|
||||
1,
|
||||
"_id",
|
||||
"ascending",
|
||||
"text",
|
||||
"text\ndoc_text\ndocument.text\nprompt",
|
||||
"{}",
|
||||
"{}",
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
2000,
|
||||
"skip record",
|
||||
"empty prompt",
|
||||
"stop at end",
|
||||
"field value list as json",
|
||||
0
|
||||
],
|
||||
"_meta": {
|
||||
"title": "🔁 ZMongo Record Selector"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ZMongoSaveTextNode",
|
||||
"pos": [20, 200],
|
||||
"size": [360, 240],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "content_to_save", "type": "STRING", "link": 1 },
|
||||
{ "name": "starting_index", "type": "INT", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "saved_file_path_or_paths", "type": "STRING", "links": [] },
|
||||
{ "name": "saved_file_count", "type": "INT", "links": [] }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"zmongo_record",
|
||||
1,
|
||||
"txt",
|
||||
"write new content",
|
||||
"separate file per item",
|
||||
true
|
||||
],
|
||||
"_meta": {
|
||||
"title": "💾 ZMongo Save Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 1, 2, 0, "STRING"],
|
||||
[2, 1, 2, 2, 1, "INT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"_meta": {
|
||||
"title": "ZMongo → Save Range of Text Fields"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ZMongoRecordLoopNode",
|
||||
"pos": [-520, 180],
|
||||
"size": [380, 460],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "selected_record_json_or_list", "type": "STRING", "links": [] },
|
||||
{ "name": "selected_field_value_or_list", "type": "STRING", "links": [1] },
|
||||
{ "name": "current_record_index", "type": "INT", "links": [2] },
|
||||
{ "name": "total_record_count", "type": "INT", "links": [] },
|
||||
{ "name": "returned_item_count", "type": "INT", "links": [] },
|
||||
{ "name": "summary", "type": "STRING", "links": [] },
|
||||
{ "name": "selection_state_json", "type": "STRING", "links": [] }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"mongodb://127.0.0.1:27017",
|
||||
"test",
|
||||
"ocr_docs",
|
||||
"record range",
|
||||
2,
|
||||
1000,
|
||||
1,
|
||||
1,
|
||||
"_id",
|
||||
"ascending",
|
||||
"text",
|
||||
"text\ndoc_text\ndocument.text\nprompt",
|
||||
"{}",
|
||||
"{}",
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
2000,
|
||||
"skip record",
|
||||
"empty prompt",
|
||||
"stop at end",
|
||||
"field value list as json",
|
||||
0
|
||||
],
|
||||
"_meta": {
|
||||
"title": "🔁 ZMongo Record Selector"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ZMongoSaveTextNode",
|
||||
"pos": [20, 200],
|
||||
"size": [360, 240],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "content_to_save", "type": "STRING", "link": 1 },
|
||||
{ "name": "starting_index", "type": "INT", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "saved_file_path_or_paths", "type": "STRING", "links": [] },
|
||||
{ "name": "saved_file_count", "type": "INT", "links": [] }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"zmongo_record",
|
||||
1,
|
||||
"txt",
|
||||
"write new content",
|
||||
"separate file per item",
|
||||
true
|
||||
],
|
||||
"_meta": {
|
||||
"title": "💾 ZMongo Save Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 1, 2, 0, "STRING"],
|
||||
[2, 1, 2, 2, 1, "INT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"_meta": {
|
||||
"title": "ZMongo → Save Range of Text Fields"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,544 @@
|
||||
{
|
||||
"last_node_id": 16,
|
||||
"last_link_id": 14,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [-2520, -180],
|
||||
"size": [960, 420],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"title": "ZMongo Workflow Guide",
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"# ZMongo Workflow Guide\n\nThis workflow demonstrates the recommended way to use the current ZMongo nodes inside ComfyUI.\n\n## What this template shows\n1. Configure the shared ZMongo connection.\n2. Browse records from a collection.\n3. Inspect the selected record JSON.\n4. Extract a field using the field selector.\n5. Select a record range using the record loop node.\n6. Save the selected field values to files.\n\n## Recommended usage pattern\n- Use **ZMongoConfigNode** first to confirm your database connection.\n- Use **ZMongoDatabaseBrowserNode** when you want to inspect real records and discover fields.\n- Use **ZMongoFieldSelector** to pick a field from a selected record.\n- Use **ZMongoRecordLoopNode** when you want one record, a range of records, or a field-value list for batch export.\n- Use **ZMongoSaveTextNode** to save one value or a JSON list of values to the ComfyUI output folder.\n\n## Typical field names\nFor OCR or document workflows, the most common field is usually:\n- `text`\n\n## Important note about ranges\nThe record loop node returns data according to its `emit_mode`. If you want to save multiple field values at once, use:\n- `emit_mode = list_of_prompts`\n\nThe save node can then split that JSON list into separate files when:\n- `save_layout = separate file per item`\n\n## Expected output folder\nSaved files will go into the ComfyUI output directory."
|
||||
],
|
||||
"color": "#2a2f38",
|
||||
"bgcolor": "#3a4250"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ZMongoConfigNode",
|
||||
"pos": [-2450, 360],
|
||||
"size": [320, 130],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "status",
|
||||
"type": "STRING",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"mongodb://127.0.0.1:27017",
|
||||
"test"
|
||||
],
|
||||
"_meta": {
|
||||
"title": "ZMongo Config"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewAny",
|
||||
"pos": [-2070, 350],
|
||||
"size": [340, 150],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [null, null, false],
|
||||
"_meta": {
|
||||
"title": "Connection Status"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [-1510, -180],
|
||||
"size": [760, 360],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"title": "How To Browse And Discover Fields",
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"## Browsing workflow\n\nUse the database browser to inspect real records from the collection before building downstream logic.\n\n### Key controls\n- **mongo_uri**: database URI\n- **database_name**: database to inspect\n- **collection_name**: collection to inspect\n- **limit**: how many records to preview\n- **select_index**: which record to fetch as the selected record\n\n### Recommended first step\nStart with:\n- collection: `ocr_docs`\n- limit: `20`\n- select_index: `1`\n\nThen inspect:\n- `selected_record_json`\n- `record_list_summary`\n- `selected_index_out`\n\nAfter that, connect `selected_record_json` into **ZMongoFieldSelector** and choose a field like `text`."
|
||||
],
|
||||
"color": "#2f3a2a",
|
||||
"bgcolor": "#45563d"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "ZMongoDatabaseBrowserNode",
|
||||
"pos": [-1490, 240],
|
||||
"size": [320, 280],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "selected_record_json",
|
||||
"type": "STRING",
|
||||
"links": [2, 4]
|
||||
},
|
||||
{
|
||||
"name": "record_list_summary",
|
||||
"type": "STRING",
|
||||
"links": [3]
|
||||
},
|
||||
{
|
||||
"name": "total_count",
|
||||
"type": "INT",
|
||||
"links": []
|
||||
},
|
||||
{
|
||||
"name": "db_name_out",
|
||||
"type": "STRING",
|
||||
"links": []
|
||||
},
|
||||
{
|
||||
"name": "coll_name_out",
|
||||
"type": "STRING",
|
||||
"links": []
|
||||
},
|
||||
{
|
||||
"name": "selected_index_out",
|
||||
"type": "INT",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"mongodb://127.0.0.1:27017",
|
||||
"test",
|
||||
"ocr_docs",
|
||||
20,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"_meta": {
|
||||
"title": "ZMongo Database Browser"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "PreviewAny",
|
||||
"pos": [-1080, 220],
|
||||
"size": [390, 420],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [null, null, false],
|
||||
"_meta": {
|
||||
"title": "Selected Record JSON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "PreviewAny",
|
||||
"pos": [-1080, 670],
|
||||
"size": [390, 260],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [null, null, false],
|
||||
"_meta": {
|
||||
"title": "Record List Summary"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "ZMongoFieldSelector",
|
||||
"pos": [-630, 320],
|
||||
"size": [300, 90],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "record_json",
|
||||
"type": "STRING",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "field_value",
|
||||
"type": "STRING",
|
||||
"links": [5]
|
||||
},
|
||||
{
|
||||
"name": "available_paths",
|
||||
"type": "STRING",
|
||||
"links": [6]
|
||||
},
|
||||
{
|
||||
"name": "selected_path",
|
||||
"type": "STRING",
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"text"
|
||||
],
|
||||
"_meta": {
|
||||
"title": "ZMongo Field Selector"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "PreviewAny",
|
||||
"pos": [-240, 250],
|
||||
"size": [360, 200],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [null, null, false],
|
||||
"_meta": {
|
||||
"title": "Selected Field Value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "PreviewAny",
|
||||
"pos": [-240, 480],
|
||||
"size": [360, 260],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 6
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [null, null, false],
|
||||
"_meta": {
|
||||
"title": "Available Field Paths"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PreviewAny",
|
||||
"pos": [-240, 770],
|
||||
"size": [240, 120],
|
||||
"flags": {},
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [null, null, false],
|
||||
"_meta": {
|
||||
"title": "Selected Path"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [180, -180],
|
||||
"size": [900, 420],
|
||||
"flags": {},
|
||||
"order": 11,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"title": "How To Use The Record Loop Node",
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"## Record loop workflow\n\nUse the record loop node when you want to select one record or many records for export or downstream generation.\n\n### Recommended meanings of the current fields\n- **mode**\n - `single` = one record\n - `range` = a record range\n - `all` = all matching records\n - `batch` = first batch from the selected range\n - `random_single` = one random record\n - `random_batch` = random batch of records\n\n- **field_path** = main field to extract, usually `text`\n- **fallback_field_paths** = backup field names\n- **emit_mode**\n - `prompt_text` = one field value\n - `record_json` = one record as JSON\n - `record_and_prompt` = one record plus one field value\n - `list_of_records` = JSON list of records\n - `list_of_prompts` = JSON list of field values\n\n### Recommended batch export setting\nTo save a range of `text` values to files, use:\n- `mode = range`\n- `start_index = 1`\n- `end_index = 4`\n- `field_path = text`\n- `emit_mode = list_of_prompts`\n\nThen connect the second output into **ZMongoSaveTextNode**."
|
||||
],
|
||||
"color": "#3a2f2a",
|
||||
"bgcolor": "#5b463f"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "ZMongoRecordLoopNode",
|
||||
"pos": [180, 280],
|
||||
"size": [360, 440],
|
||||
"flags": {},
|
||||
"order": 12,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "record_data",
|
||||
"type": "STRING",
|
||||
"links": []
|
||||
},
|
||||
{
|
||||
"name": "selected_field_text",
|
||||
"type": "STRING",
|
||||
"links": [8]
|
||||
},
|
||||
{
|
||||
"name": "current_index",
|
||||
"type": "INT",
|
||||
"links": [9]
|
||||
},
|
||||
{
|
||||
"name": "total_count",
|
||||
"type": "INT",
|
||||
"links": []
|
||||
},
|
||||
{
|
||||
"name": "emitted_count",
|
||||
"type": "INT",
|
||||
"links": []
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "STRING",
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "loop_state_json",
|
||||
"type": "STRING",
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"mongodb://127.0.0.1:27017",
|
||||
"test",
|
||||
"ocr_docs",
|
||||
"range",
|
||||
1,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
"_id",
|
||||
"ascending",
|
||||
"text",
|
||||
"text\ndoc_text\ndocument.text\nprompt",
|
||||
"{}",
|
||||
"{}",
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
2000,
|
||||
"skip",
|
||||
"empty prompt",
|
||||
"stop_at_end",
|
||||
"list_of_prompts",
|
||||
0
|
||||
],
|
||||
"_meta": {
|
||||
"title": "ZMongo Record Loop"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"type": "ZMongoSaveTextNode",
|
||||
"pos": [650, 320],
|
||||
"size": [360, 250],
|
||||
"flags": {},
|
||||
"order": 13,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "content_to_save",
|
||||
"type": "STRING",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"name": "starting_index",
|
||||
"type": "INT",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "saved_file_path_or_paths",
|
||||
"type": "STRING",
|
||||
"links": [12]
|
||||
},
|
||||
{
|
||||
"name": "saved_file_count",
|
||||
"type": "INT",
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"zmongo_export",
|
||||
1,
|
||||
"txt",
|
||||
"write new content",
|
||||
"separate file per item",
|
||||
true
|
||||
],
|
||||
"_meta": {
|
||||
"title": "ZMongo Save Text"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"type": "PreviewAny",
|
||||
"pos": [1050, 260],
|
||||
"size": [400, 220],
|
||||
"flags": {},
|
||||
"order": 14,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 12
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [null, null, false],
|
||||
"_meta": {
|
||||
"title": "Saved File Paths"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"type": "PreviewAny",
|
||||
"pos": [1050, 520],
|
||||
"size": [300, 140],
|
||||
"flags": {},
|
||||
"order": 15,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [null, null, false],
|
||||
"_meta": {
|
||||
"title": "Saved File Count"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"type": "PreviewAny",
|
||||
"pos": [650, 620],
|
||||
"size": [360, 220],
|
||||
"flags": {},
|
||||
"order": 16,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [null, null, false],
|
||||
"_meta": {
|
||||
"title": "Loop Summary"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"type": "PreviewAny",
|
||||
"pos": [650, 870],
|
||||
"size": [360, 260],
|
||||
"flags": {},
|
||||
"order": 17,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 11
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [null, null, false],
|
||||
"_meta": {
|
||||
"title": "Loop State JSON"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 3, 0, "STRING"],
|
||||
[2, 5, 0, 6, 0, "STRING"],
|
||||
[3, 5, 1, 7, 0, "STRING"],
|
||||
[4, 5, 0, 8, 0, "STRING"],
|
||||
[5, 8, 0, 9, 0, "STRING"],
|
||||
[6, 8, 1, 10, 0, "STRING"],
|
||||
[7, 8, 2, 11, 0, "STRING"],
|
||||
[8, 13, 1, 14, 0, "STRING"],
|
||||
[9, 13, 2, 14, 1, "INT"],
|
||||
[10, 13, 5, 17, 0, "STRING"],
|
||||
[11, 13, 6, 18, 0, "STRING"],
|
||||
[12, 14, 0, 15, 0, "STRING"],
|
||||
[13, 14, 1, 16, 0, "INT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"_meta": {
|
||||
"title": "ZMongo Guided Workflow Template"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,81 @@
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
from .zmongo_toolbag.local_vector_search import LocalVectorSearch
|
||||
from .zmongo_toolbag.safe_result import SafeResult
|
||||
from .zmongo_toolbag.zembedder import ZEmbedder
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
from .preset_api import register_preset_routes
|
||||
|
||||
from .zmongo_field_selector_api import register_zmongo_field_selector_routes
|
||||
from .zmongo_field_selector_node import ZMongoFieldSelectorNode
|
||||
from .zmongo_flattened_field_selector_node import ZMongoFlattenedFieldSelectorNode
|
||||
from .zmongo_record_editor_api import register_zmongo_record_editor_routes
|
||||
from .zmongo_record_editor_node import ZMongoRecordEditorNode
|
||||
from .zmongo_tabular_record_view_api import register_zmongo_tabular_record_view_routes
|
||||
from .zmongo_tabular_record_view_node import ZMongoTabularRecordViewNode
|
||||
from .zmongo_nodes import (
|
||||
ZMongoConfigNode,
|
||||
ZMongoDataPassThroughNode,
|
||||
ZMongoDatabaseBrowserNode,
|
||||
ZMongoFieldSelector,
|
||||
ZMongoOperationsNode,
|
||||
ZMongoRecordLoopNode,
|
||||
ZMongoRecordSplitter,
|
||||
ZMongoSaveBatchTextNode,
|
||||
ZMongoSaveTextNode,
|
||||
ZMongoSaveValueNode,
|
||||
ZMongoTextFetcher,
|
||||
ZRetrieverNode,
|
||||
)
|
||||
|
||||
from .model_loader.zmongo_model_compatibility_display_node import (
|
||||
ZMongoModelCompatibilityDisplayNode,
|
||||
)
|
||||
from .model_loader.zmongo_model_introspector_node import (
|
||||
ZMongoModelIntrospectorNode,
|
||||
)
|
||||
from .model_loader.zmongo_universal_model_selector_node import (
|
||||
ZMongoUniversalModelSelectorNode,
|
||||
)
|
||||
from .model_loader.zmongo_universal_model_loader_node import (
|
||||
ZMongoUniversalModelLoaderNode,
|
||||
)
|
||||
from .model_loader.zmongo_universal_adapter_node import (
|
||||
ZMongoUniversalModelAdapterNode,
|
||||
)
|
||||
|
||||
from .model_loader.zmongo_builtin_loader_adapter_node import (
|
||||
ZMongoBuiltInLoaderAdapterNode,
|
||||
)
|
||||
__all__ = [
|
||||
"register_preset_routes",
|
||||
"register_zmongo_field_selector_routes",
|
||||
"register_zmongo_tabular_record_view_routes",
|
||||
"register_zmongo_record_editor_routes",
|
||||
"ZMongoConfigNode",
|
||||
"ZMongoTextFetcher",
|
||||
"ZMongoOperationsNode",
|
||||
"ZMongoRecordSplitter",
|
||||
"ZMongoFieldSelector",
|
||||
"ZRetrieverNode",
|
||||
"ZMongoDatabaseBrowserNode",
|
||||
"ZMongoRecordLoopNode",
|
||||
"ZMongoSaveTextNode",
|
||||
"ZMongoDataPassThroughNode",
|
||||
"ZMongoSaveBatchTextNode",
|
||||
"ZMongoSaveValueNode",
|
||||
"ZMongoFlattenedFieldSelectorNode",
|
||||
"ZMongoFieldSelectorNode",
|
||||
"ZMongoTabularRecordViewNode",
|
||||
"ZMongoRecordEditorNode",
|
||||
"ZMongoModelIntrospectorNode",
|
||||
"ZMongoModelCompatibilityDisplayNode",
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoUniversalModelLoaderNode",
|
||||
"ZMongoUniversalModelAdapterNode",
|
||||
"ZMongoBuiltInLoaderAdapterNode",
|
||||
"DataProcessor",
|
||||
"SafeResult",
|
||||
"ZMongo",
|
||||
"ZEmbedder",
|
||||
"LocalVectorSearch",
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from .zmongo_manager import ZMongoManager
|
||||
except Exception:
|
||||
from .zmongo_manager import ZMongoManager
|
||||
|
||||
try:
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
except Exception:
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
try:
|
||||
from .zmongo_toolbag.zembedder import ZEmbedder
|
||||
except Exception:
|
||||
from .zmongo_toolbag.zembedder import ZEmbedder
|
||||
|
||||
try:
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
except Exception:
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
|
||||
try:
|
||||
from .zmongo_toolbag.safe_result import SafeResult
|
||||
except Exception:
|
||||
from .zmongo_toolbag.safe_result import SafeResult
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# Available in PyMongo >= 4.x
|
||||
from bson import BSON, decode_file_iter
|
||||
|
||||
HAVE_BSON_STREAM = True
|
||||
except Exception:
|
||||
HAVE_BSON_STREAM = False
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PRESET_COLLECTION = "comfyui_presets"
|
||||
zmongo = ZMongo()
|
||||
|
||||
|
||||
def _doc_to_jsonable(doc):
|
||||
if not doc:
|
||||
return doc
|
||||
out = dict(doc)
|
||||
if "_id" in out:
|
||||
out["_id"] = str(out["_id"])
|
||||
return out
|
||||
|
||||
|
||||
async def list_presets(request):
|
||||
workflow = request.query.get("workflow")
|
||||
query = {"workflow": workflow} if workflow else {}
|
||||
|
||||
res = await zmongo.find_many_async(PRESET_COLLECTION, query=query, limit=1000)
|
||||
if not res.success:
|
||||
return web.json_response({"success": False, "error": res.error}, status=500)
|
||||
|
||||
docs = [_doc_to_jsonable(d) for d in (res.data or [])]
|
||||
return web.json_response({"success": True, "presets": docs})
|
||||
|
||||
|
||||
async def save_preset(request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as e:
|
||||
return web.json_response({"success": False, "error": f"Invalid JSON: {e}"}, status=400)
|
||||
|
||||
name = payload.get("name")
|
||||
workflow = payload.get("workflow", "default")
|
||||
group = payload.get("group", "Default")
|
||||
updates = payload.get("updates", {})
|
||||
|
||||
if not name:
|
||||
return web.json_response({"success": False, "error": "Missing preset name"}, status=400)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
query = {"workflow": workflow, "name": name}
|
||||
doc = {
|
||||
"workflow": workflow,
|
||||
"name": name,
|
||||
"group": group,
|
||||
"updates": updates,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
res = await zmongo.insert_or_update_async(PRESET_COLLECTION, query, doc)
|
||||
if not res.success:
|
||||
return web.json_response({"success": False, "error": res.error}, status=500)
|
||||
|
||||
return web.json_response({"success": True, "message": "Preset saved"})
|
||||
|
||||
|
||||
async def delete_preset(request):
|
||||
workflow = request.match_info.get("workflow")
|
||||
name = request.match_info.get("name")
|
||||
|
||||
if not workflow or not name:
|
||||
return web.json_response({"success": False, "error": "Missing workflow or name"}, status=400)
|
||||
|
||||
res = await zmongo.delete_many_async(PRESET_COLLECTION, {"workflow": workflow, "name": name})
|
||||
if not res.success:
|
||||
return web.json_response({"success": False, "error": res.error}, status=500)
|
||||
|
||||
return web.json_response({"success": True, "message": "Preset deleted"})
|
||||
|
||||
|
||||
def register_preset_routes(prompt_server):
|
||||
routes = prompt_server.routes
|
||||
|
||||
routes.get("/zmongo/presets")(list_presets)
|
||||
routes.post("/zmongo/presets")(save_preset)
|
||||
routes.delete("/zmongo/presets/{workflow}/{name}")(delete_preset)
|
||||
|
||||
logger.info("[ZMongo] Preset API routes registered")
|
||||
@@ -0,0 +1,81 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
from .zmongo_admin_tabs import ZMongoAdminTabs
|
||||
from .zmongo_manager import ZMongoManager
|
||||
|
||||
|
||||
class ZAdminGUI(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("ZMongo Professional Admin System")
|
||||
self.geometry("1400x900")
|
||||
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.loop_thread = threading.Thread(
|
||||
target=self._run_loop,
|
||||
name="zadmin-async-loop",
|
||||
daemon=True,
|
||||
)
|
||||
self.loop_thread.start()
|
||||
|
||||
self.engine = self._init_engine()
|
||||
self._init_ui()
|
||||
|
||||
self.protocol("WM_DELETE_WINDOW", self.on_close)
|
||||
|
||||
def _run_loop(self):
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_forever()
|
||||
|
||||
def _init_engine(self):
|
||||
"""
|
||||
Initialize the async singleton correctly and wait for the real instance.
|
||||
"""
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
ZMongoManager.get_instance(),
|
||||
self.loop,
|
||||
)
|
||||
return future.result()
|
||||
|
||||
def _init_ui(self):
|
||||
self.main_notebook = ttk.Notebook(self)
|
||||
self.main_notebook.pack(expand=True, fill="both", padx=10, pady=10)
|
||||
|
||||
self.tabs = ZMongoAdminTabs(
|
||||
self.main_notebook,
|
||||
self.engine,
|
||||
self.loop,
|
||||
)
|
||||
|
||||
def on_close(self):
|
||||
try:
|
||||
if getattr(self, "engine", None) is not None:
|
||||
try:
|
||||
if hasattr(self.engine, "async_client"):
|
||||
self.engine.async_client.close()
|
||||
if hasattr(self.engine, "sync_client"):
|
||||
self.engine.sync_client.close()
|
||||
except Exception as exc:
|
||||
logging.error("Error closing Mongo clients: %s", exc)
|
||||
|
||||
try:
|
||||
type(self.engine)._instance = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(self, "loop") and self.loop.is_running():
|
||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||
|
||||
if hasattr(self, "loop_thread") and self.loop_thread.is_alive():
|
||||
self.loop_thread.join(timeout=1.0)
|
||||
finally:
|
||||
self.destroy()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = ZAdminGUI()
|
||||
app.mainloop()
|
||||
@@ -0,0 +1,144 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import ttk
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from pymongo import MongoClient
|
||||
|
||||
from .zmongo_admin_tabs import ZMongoAdminTabs
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
|
||||
load_dotenv(Path.home() / ".resources" / ".env")
|
||||
|
||||
MONGO_URI = os.getenv("MONGO_URI", "mongodb://127.0.0.1:27017")
|
||||
MONGO_DATABASE_NAME = os.getenv("MONGO_DATABASE_NAME", "test")
|
||||
|
||||
|
||||
class ZMongoAdminBackend:
|
||||
"""
|
||||
Non-GUI backend bridge for the admin GUI.
|
||||
|
||||
This is what ZMongoAdminTabs should receive.
|
||||
"""
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop):
|
||||
self.loop = loop
|
||||
self.zmongo = ZMongo()
|
||||
|
||||
self.async_client = AsyncIOMotorClient(MONGO_URI)
|
||||
self.db = self.async_client[MONGO_DATABASE_NAME]
|
||||
|
||||
self.sync_client = MongoClient(MONGO_URI)
|
||||
self.sync_db = self.sync_client[MONGO_DATABASE_NAME]
|
||||
|
||||
self.comfyui_to_zmongo: queue.Queue = queue.Queue()
|
||||
self.zmongo_to_comfyui: queue.Queue = queue.Queue()
|
||||
|
||||
def run_in_async_loop(self, async_func, *args, **kwargs):
|
||||
return asyncio.run_coroutine_threadsafe(async_func(*args, **kwargs), self.loop)
|
||||
|
||||
def submit_from_comfyui(self, payload: dict):
|
||||
self.comfyui_to_zmongo.put(payload)
|
||||
|
||||
def submit_to_comfyui(self, payload: dict):
|
||||
self.zmongo_to_comfyui.put(payload)
|
||||
|
||||
async def process_comfyui_payload(self, payload: dict):
|
||||
"""
|
||||
Store inbound ComfyUI events/results in Mongo.
|
||||
"""
|
||||
document = {
|
||||
"source": "comfyui",
|
||||
"payload_type": payload.get("type", "unknown"),
|
||||
"payload": payload,
|
||||
}
|
||||
return await self.zmongo.insert_document("comfyui_events", document)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.async_client.close()
|
||||
except Exception as exc:
|
||||
logging.error("Error closing async Mongo client: %s", exc)
|
||||
|
||||
try:
|
||||
self.sync_client.close()
|
||||
except Exception as exc:
|
||||
logging.error("Error closing sync Mongo client: %s", exc)
|
||||
|
||||
|
||||
class ZAdminGUI(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("ZMongo Professional Admin System")
|
||||
self.geometry("1400x900")
|
||||
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.loop_thread = threading.Thread(
|
||||
target=self._run_loop,
|
||||
name="zadmin-async-loop",
|
||||
daemon=True,
|
||||
)
|
||||
self.loop_thread.start()
|
||||
|
||||
self.engine = ZMongoAdminBackend(self.loop)
|
||||
|
||||
self.main_notebook = ttk.Notebook(self)
|
||||
self.main_notebook.pack(expand=True, fill="both", padx=10, pady=10)
|
||||
|
||||
self.tabs = ZMongoAdminTabs(self.main_notebook, self.engine, self.loop)
|
||||
|
||||
self.after(100, self._pump_bridge)
|
||||
self.protocol("WM_DELETE_WINDOW", self.on_close)
|
||||
|
||||
def _run_loop(self):
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_forever()
|
||||
|
||||
def _pump_bridge(self):
|
||||
"""
|
||||
Pump ComfyUI -> Mongo payloads from the Tk thread.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
payload = self.engine.comfyui_to_zmongo.get_nowait()
|
||||
future = self.engine.run_in_async_loop(self.engine.process_comfyui_payload, payload)
|
||||
future.add_done_callback(self._handle_bridge_result)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
self.after(100, self._pump_bridge)
|
||||
|
||||
def _handle_bridge_result(self, future):
|
||||
try:
|
||||
result = future.result()
|
||||
self.engine.submit_to_comfyui({
|
||||
"type": "zmongo_ack",
|
||||
"success": getattr(result, "success", False),
|
||||
"data": getattr(result, "data", None),
|
||||
"error": getattr(result, "error", None),
|
||||
})
|
||||
except Exception as exc:
|
||||
logging.error("Bridge task failed: %s", exc)
|
||||
|
||||
def on_close(self):
|
||||
try:
|
||||
if getattr(self, "engine", None) is not None:
|
||||
self.engine.close()
|
||||
|
||||
if hasattr(self, "loop") and self.loop.is_running():
|
||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||
|
||||
if hasattr(self, "loop_thread") and self.loop_thread.is_alive():
|
||||
self.loop_thread.join(timeout=1.0)
|
||||
finally:
|
||||
self.destroy()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = ZAdminGUI()
|
||||
app.mainloop()
|
||||
@@ -0,0 +1,608 @@
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tkinter import (
|
||||
BOTH,
|
||||
END,
|
||||
LEFT,
|
||||
RIGHT,
|
||||
TOP,
|
||||
X,
|
||||
Y,
|
||||
Button,
|
||||
Entry,
|
||||
Frame,
|
||||
Listbox,
|
||||
filedialog,
|
||||
ttk,
|
||||
)
|
||||
from tkinter.scrolledtext import ScrolledText
|
||||
import tkinter as tk
|
||||
|
||||
from bson import errors
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
from .zmongo_toolbag.safe_result import SafeResult
|
||||
|
||||
try:
|
||||
from bson import BSON, decode_file_iter
|
||||
|
||||
HAVE_BSON_STREAM = True
|
||||
except Exception:
|
||||
HAVE_BSON_STREAM = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pager:
|
||||
limit: int = 100
|
||||
skip: int = 0
|
||||
|
||||
|
||||
class ZMongoAdminTabs:
|
||||
"""
|
||||
Modular admin tabs for Mongo/ZMongo management.
|
||||
|
||||
Supports either:
|
||||
- legacy wrapper objects exposing `.client`
|
||||
- newer backend objects exposing `.zmongo`
|
||||
- raw ZMongo-like objects directly
|
||||
"""
|
||||
|
||||
def __init__(self, parent_notebook: ttk.Notebook, zmongo_singleton, loop: asyncio.AbstractEventLoop):
|
||||
self.parent = parent_notebook
|
||||
self.notebook = parent_notebook
|
||||
self.loop = loop
|
||||
self.manager = zmongo_singleton
|
||||
|
||||
self.zmongo = self._resolve_zmongo(zmongo_singleton)
|
||||
self.db = self._resolve_db(zmongo_singleton, self.zmongo)
|
||||
self.sync_db = getattr(zmongo_singleton, "sync_db", None)
|
||||
|
||||
if self.db is None:
|
||||
raise AttributeError(
|
||||
"ZMongoAdminTabs requires an object with either '.client' or '.zmongo', "
|
||||
"and a usable '.db' attribute."
|
||||
)
|
||||
|
||||
self.db_name = self._resolve_db_name(zmongo_singleton, self.zmongo)
|
||||
|
||||
self.backup_dir = Path.home() / os.getenv("MONGO_BACKUP_DIR", ".resources/backups") / self.db_name
|
||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.cv_pager = Pager(limit=100, skip=0)
|
||||
self.cv_ids_cache: list[tuple[str, object]] = []
|
||||
self.active_processes: dict[str, subprocess.Popen] = {}
|
||||
|
||||
self.collection_listbox = None
|
||||
self.backup_files_listbox = None
|
||||
self.backup_format_combo = None
|
||||
self.message_text = None
|
||||
self.console = None
|
||||
self.cv_collection_entry = None
|
||||
self.cv_filter_entry = None
|
||||
self.cv_doc_listbox = None
|
||||
self.cv_json_text = None
|
||||
self.cv_dotkey_entry = None
|
||||
self.cv_value_entry = None
|
||||
|
||||
self._build_tabs(parent_notebook)
|
||||
self.run_periodic_updates()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_zmongo(zmongo_singleton):
|
||||
if hasattr(zmongo_singleton, "client"):
|
||||
return zmongo_singleton.client
|
||||
if hasattr(zmongo_singleton, "zmongo"):
|
||||
return zmongo_singleton.zmongo
|
||||
return zmongo_singleton
|
||||
|
||||
@staticmethod
|
||||
def _resolve_db(zmongo_singleton, zmongo_obj):
|
||||
direct_db = getattr(zmongo_singleton, "db", None)
|
||||
if direct_db is not None:
|
||||
return direct_db
|
||||
return getattr(zmongo_obj, "db", None)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_db_name(zmongo_singleton, zmongo_obj):
|
||||
db_name = getattr(zmongo_singleton, "db_name", None)
|
||||
if db_name:
|
||||
return db_name
|
||||
db_name = getattr(zmongo_obj, "db_name", None)
|
||||
if db_name:
|
||||
return db_name
|
||||
db = getattr(zmongo_singleton, "db", None) or getattr(zmongo_obj, "db", None)
|
||||
if db is not None and hasattr(db, "name"):
|
||||
return db.name
|
||||
return os.getenv("MONGO_DATABASE_NAME", "test")
|
||||
|
||||
def _build_tabs(self, notebook):
|
||||
self.explorer_frame = ttk.Frame(notebook)
|
||||
notebook.add(self.explorer_frame, text="Collection Viewer")
|
||||
self._build_collection_tab(self.explorer_frame)
|
||||
|
||||
self.maintenance_frame = ttk.Frame(notebook)
|
||||
notebook.add(self.maintenance_frame, text="Backup & Restore")
|
||||
self._build_maintenance_tab(self.maintenance_frame)
|
||||
|
||||
self.system_frame = ttk.Frame(notebook)
|
||||
notebook.add(self.system_frame, text="Service Runners")
|
||||
self._build_system_tab(self.system_frame)
|
||||
|
||||
def _build_collection_tab(self, parent: Frame):
|
||||
root = ttk.Frame(parent)
|
||||
root.pack(fill=BOTH, expand=True, padx=8, pady=8)
|
||||
root.grid_columnconfigure(1, weight=1)
|
||||
root.grid_rowconfigure(2, weight=1)
|
||||
|
||||
ttk.Label(root, text="Collection:").grid(row=0, column=0, sticky="w")
|
||||
self.cv_collection_entry = Entry(root, width=40)
|
||||
self.cv_collection_entry.grid(row=0, column=1, sticky="w", padx=6)
|
||||
|
||||
ttk.Label(root, text="Filter (JSON):").grid(row=1, column=0, sticky="w")
|
||||
self.cv_filter_entry = Entry(root)
|
||||
self.cv_filter_entry.insert(0, "{}")
|
||||
self.cv_filter_entry.grid(row=1, column=1, sticky="we", padx=6)
|
||||
|
||||
ctrl = ttk.Frame(root)
|
||||
ctrl.grid(row=0, column=2, rowspan=2, sticky="ne")
|
||||
Button(ctrl, text="Refresh", command=self.cv_refresh_docs_clicked).pack(side=TOP, padx=4, pady=2)
|
||||
Button(ctrl, text="Load More", command=self.cv_load_more_clicked).pack(side=TOP, padx=4, pady=2)
|
||||
|
||||
left = ttk.Frame(root, borderwidth=1, relief="groove")
|
||||
left.grid(row=2, column=0, sticky="ns")
|
||||
|
||||
self.cv_doc_listbox = Listbox(left, height=30, width=36, exportselection=False)
|
||||
self.cv_doc_listbox.pack(side=LEFT, fill=Y)
|
||||
self.cv_doc_listbox.bind("<<ListboxSelect>>", self.cv_on_doc_select)
|
||||
|
||||
scrollbar = ttk.Scrollbar(left, orient="vertical", command=self.cv_doc_listbox.yview)
|
||||
scrollbar.pack(side=RIGHT, fill=Y)
|
||||
self.cv_doc_listbox.config(yscrollcommand=scrollbar.set)
|
||||
|
||||
right = ttk.Frame(root, borderwidth=1, relief="groove")
|
||||
right.grid(row=2, column=1, columnspan=2, sticky="nswe", padx=(8, 0))
|
||||
right.grid_columnconfigure(0, weight=1)
|
||||
right.grid_rowconfigure(0, weight=1)
|
||||
|
||||
self.cv_json_text = ScrolledText(right, font=("Courier New", 10), wrap="none")
|
||||
self.cv_json_text.grid(row=0, column=0, sticky="nswe", padx=5, pady=5)
|
||||
|
||||
editor = ttk.LabelFrame(right, text="Dot-Key Update (Single Doc)")
|
||||
editor.grid(row=1, column=0, sticky="we", padx=5, pady=5)
|
||||
|
||||
ttk.Label(editor, text="Dot-key:").pack(side=LEFT, padx=5)
|
||||
self.cv_dotkey_entry = Entry(editor, width=30)
|
||||
self.cv_dotkey_entry.pack(side=LEFT, padx=5)
|
||||
|
||||
ttk.Label(editor, text="Value:").pack(side=LEFT, padx=5)
|
||||
self.cv_value_entry = Entry(editor, width=30)
|
||||
self.cv_value_entry.pack(side=LEFT, padx=5)
|
||||
|
||||
Button(editor, text="Apply $set", command=self.on_apply_dotkey_value_clicked).pack(side=RIGHT, padx=5)
|
||||
|
||||
def _build_maintenance_tab(self, parent: Frame):
|
||||
root = ttk.Frame(parent)
|
||||
root.pack(fill=BOTH, expand=True, padx=8, pady=8)
|
||||
root.grid_columnconfigure(0, weight=1)
|
||||
root.grid_columnconfigure(2, weight=1)
|
||||
root.grid_rowconfigure(3, weight=1)
|
||||
|
||||
ttk.Label(root, text="Collections:").grid(row=0, column=0, sticky="w", padx=5)
|
||||
self.collection_listbox = Listbox(root, exportselection=False, height=12)
|
||||
self.collection_listbox.grid(row=1, column=0, sticky="nswe", padx=5)
|
||||
self.collection_listbox.bind("<<ListboxSelect>>", self.on_collection_select)
|
||||
|
||||
ttk.Label(root, text="Backup Files:").grid(row=0, column=2, sticky="w", padx=5)
|
||||
self.backup_files_listbox = Listbox(root, exportselection=False, height=12)
|
||||
self.backup_files_listbox.grid(row=1, column=2, sticky="nswe", padx=5)
|
||||
self.backup_files_listbox.bind("<<ListboxSelect>>", self.on_backup_file_select)
|
||||
|
||||
actions = ttk.Frame(root)
|
||||
actions.grid(row=2, column=0, columnspan=4, sticky="we", pady=10)
|
||||
|
||||
self.backup_format_combo = ttk.Combobox(actions, values=["JSON", "BSON", "CSV"], state="readonly", width=8)
|
||||
self.backup_format_combo.current(0)
|
||||
self.backup_format_combo.pack(side=LEFT, padx=5)
|
||||
|
||||
Button(actions, text="Backup Selected", command=self.on_backup_selected_clicked).pack(side=LEFT, padx=5)
|
||||
Button(actions, text="Restore Selected", command=self.on_restore_clicked).pack(side=LEFT, padx=5)
|
||||
Button(actions, text="Browse File...", command=self.open_file_explorer).pack(side=LEFT, padx=5)
|
||||
|
||||
self.message_text = ScrolledText(root, height=8, wrap="word", state="disabled")
|
||||
self.message_text.grid(row=3, column=0, columnspan=4, sticky="nswe", padx=5, pady=5)
|
||||
|
||||
def _build_system_tab(self, parent):
|
||||
controls = ttk.LabelFrame(parent, text="Process Controls")
|
||||
controls.pack(fill=X, padx=10, pady=10)
|
||||
|
||||
tk.Button(controls, text="Start OCR Runner", command=lambda: self.start_service("ocr_runner.py")).pack(
|
||||
side=LEFT, padx=5
|
||||
)
|
||||
tk.Button(controls, text="Start ZRetriever API", command=lambda: self.start_service("zretriever.py")).pack(
|
||||
side=LEFT, padx=5
|
||||
)
|
||||
|
||||
self.console = ScrolledText(parent, height=20, bg="black", fg="white")
|
||||
self.console.pack(expand=True, fill=BOTH, padx=10, pady=10)
|
||||
|
||||
def start_service(self, script_path):
|
||||
def _stream():
|
||||
self.notebook.after(0, lambda: self.console.insert(END, f"\n[SYSTEM] Starting {script_path}...\n"))
|
||||
process = subprocess.Popen(
|
||||
["python", script_path],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
self.active_processes[script_path] = process
|
||||
for line in process.stdout:
|
||||
self.notebook.after(0, lambda l=line: (self.console.insert(END, l), self.console.see(END)))
|
||||
|
||||
threading.Thread(target=_stream, daemon=True).start()
|
||||
|
||||
def run_periodic_updates(self):
|
||||
self.run_in_async_loop(self.fetch_and_update_db_info)
|
||||
self.run_in_async_loop(self.fetch_and_update_collections)
|
||||
self.notebook.after(30000, self.run_periodic_updates)
|
||||
|
||||
async def fetch_and_update_db_info(self):
|
||||
try:
|
||||
await self.db.list_collection_names()
|
||||
await self.db.command("dbstats")
|
||||
except Exception as e:
|
||||
logger.error("DB Info update failed: %s", e)
|
||||
|
||||
async def fetch_and_update_collections(self):
|
||||
try:
|
||||
names = sorted(await self.db.list_collection_names())
|
||||
self.notebook.after(0, lambda: self._update_collection_listbox(names))
|
||||
except Exception as e:
|
||||
logger.error("Collection list update failed: %s", e)
|
||||
|
||||
def _update_collection_listbox(self, names):
|
||||
self.collection_listbox.delete(0, END)
|
||||
for name in names:
|
||||
self.collection_listbox.insert(END, name)
|
||||
|
||||
def run_in_async_loop(self, async_func, *args, **kwargs):
|
||||
future = asyncio.run_coroutine_threadsafe(async_func(*args, **kwargs), self.loop)
|
||||
future.add_done_callback(self.on_async_task_done)
|
||||
return future
|
||||
|
||||
def on_async_task_done(self, future):
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
self.log_message(f"Async Error: {e}")
|
||||
|
||||
def log_message(self, message):
|
||||
if self.message_text is None:
|
||||
logger.error(message)
|
||||
return
|
||||
|
||||
def _append():
|
||||
self.message_text.config(state="normal")
|
||||
self.message_text.insert(END, f"{datetime.now().strftime('%H:%M:%S')} - {message}\n")
|
||||
self.message_text.config(state="disabled")
|
||||
self.message_text.see(END)
|
||||
|
||||
self.notebook.after(0, _append)
|
||||
|
||||
def cv_refresh_docs_clicked(self):
|
||||
self.cv_pager.skip = 0
|
||||
self.cv_refresh_docs(reset=True)
|
||||
|
||||
def cv_load_more_clicked(self):
|
||||
self.cv_pager.skip += self.cv_pager.limit
|
||||
self.cv_refresh_docs(reset=False)
|
||||
|
||||
def cv_refresh_docs(self, reset: bool):
|
||||
collection = self.cv_collection_entry.get().strip()
|
||||
if not collection:
|
||||
self.log_message("Collection is required.")
|
||||
return
|
||||
|
||||
try:
|
||||
filt = json.loads(self.cv_filter_entry.get() or "{}")
|
||||
if not isinstance(filt, dict):
|
||||
raise ValueError
|
||||
except Exception:
|
||||
filt = {}
|
||||
self.log_message("Invalid JSON filter; using {}.")
|
||||
|
||||
async def _fetch():
|
||||
cursor = self.db[collection].find(filt, projection={"_id": 1}).skip(self.cv_pager.skip).limit(
|
||||
self.cv_pager.limit
|
||||
)
|
||||
items = await cursor.to_list(length=self.cv_pager.limit)
|
||||
ids = [(str(it.get("_id")), it.get("_id")) for it in items]
|
||||
self.notebook.after(0, lambda: self._update_cv_listbox(ids, reset))
|
||||
|
||||
self.run_in_async_loop(_fetch)
|
||||
|
||||
def _update_cv_listbox(self, ids, reset):
|
||||
if reset:
|
||||
self.cv_doc_listbox.delete(0, END)
|
||||
self.cv_ids_cache = []
|
||||
|
||||
self.cv_ids_cache.extend(ids)
|
||||
for sid, _ in ids:
|
||||
self.cv_doc_listbox.insert(END, sid)
|
||||
|
||||
def cv_on_doc_select(self, event=None):
|
||||
selection = self.cv_doc_listbox.curselection()
|
||||
if not selection:
|
||||
return
|
||||
|
||||
_, _id_obj = self.cv_ids_cache[selection[0]]
|
||||
collection = self.cv_collection_entry.get().strip()
|
||||
if not collection:
|
||||
return
|
||||
|
||||
async def _fetch():
|
||||
if hasattr(self.zmongo, "find_one_async"):
|
||||
res = await self.zmongo.find_one_async(collection, {"_id": _id_obj})
|
||||
elif hasattr(self.zmongo, "find_one"):
|
||||
maybe = self.zmongo.find_one(collection, {"_id": _id_obj})
|
||||
res = await maybe if asyncio.iscoroutine(maybe) else maybe
|
||||
else:
|
||||
raise AttributeError("Resolved ZMongo object does not expose find_one_async or find_one.")
|
||||
|
||||
if isinstance(res, SafeResult) and res.success:
|
||||
pretty = json.dumps(res.data, indent=2, default=str)
|
||||
self.notebook.after(
|
||||
0,
|
||||
lambda: (
|
||||
self.cv_json_text.delete("1.0", END),
|
||||
self.cv_json_text.insert(END, pretty),
|
||||
),
|
||||
)
|
||||
elif isinstance(res, SafeResult):
|
||||
self.log_message(f"Fetch failed: {res.error}")
|
||||
else:
|
||||
pretty = json.dumps(res, indent=2, default=str)
|
||||
self.notebook.after(
|
||||
0,
|
||||
lambda: (
|
||||
self.cv_json_text.delete("1.0", END),
|
||||
self.cv_json_text.insert(END, pretty),
|
||||
),
|
||||
)
|
||||
|
||||
self.run_in_async_loop(_fetch)
|
||||
|
||||
def on_apply_dotkey_value_clicked(self):
|
||||
collection = self.cv_collection_entry.get().strip()
|
||||
path = self.cv_dotkey_entry.get().strip()
|
||||
raw_val = self.cv_value_entry.get().strip()
|
||||
selection = self.cv_doc_listbox.curselection()
|
||||
|
||||
if not all([collection, path, selection]):
|
||||
self.log_message("Collection, dot-key, and selected document are required.")
|
||||
return
|
||||
|
||||
_id_str, _id_obj = self.cv_ids_cache[selection[0]]
|
||||
|
||||
try:
|
||||
value = json.loads(raw_val)
|
||||
except Exception:
|
||||
value = raw_val
|
||||
|
||||
async def _update():
|
||||
if hasattr(self.zmongo, "update_one_async"):
|
||||
res = await self.zmongo.update_one_async(collection, {"_id": _id_obj}, {"$set": {path: value}})
|
||||
elif hasattr(self.zmongo, "update_many_async"):
|
||||
res = await self.zmongo.update_many_async(collection, {"_id": _id_obj}, {"$set": {path: value}})
|
||||
else:
|
||||
raise AttributeError("Resolved ZMongo object does not expose update_one_async or update_many_async.")
|
||||
|
||||
if isinstance(res, SafeResult) and res.success:
|
||||
self.log_message(f"Updated {path} for {_id_str}")
|
||||
self.cv_on_doc_select()
|
||||
elif isinstance(res, SafeResult):
|
||||
self.log_message(f"Update failed: {res.error}")
|
||||
else:
|
||||
self.log_message(f"Update completed for {_id_str}")
|
||||
|
||||
self.run_in_async_loop(_update)
|
||||
|
||||
def on_collection_select(self, event=None):
|
||||
selection = self.collection_listbox.curselection()
|
||||
if selection:
|
||||
collection_name = self.collection_listbox.get(selection[0])
|
||||
self.update_backup_files_listbox(collection_name)
|
||||
|
||||
def update_backup_files_listbox(self, collection_name: str):
|
||||
self.backup_files_listbox.delete(0, END)
|
||||
pattern = re.compile(rf"^{re.escape(collection_name)}\[\d{{14}}\]\.(json|bson|csv)$", re.I)
|
||||
|
||||
try:
|
||||
for file_path in self.backup_dir.iterdir():
|
||||
if file_path.is_file() and pattern.match(file_path.name):
|
||||
self.backup_files_listbox.insert(END, file_path.name)
|
||||
except FileNotFoundError:
|
||||
self.log_message(f"Backup directory not found: {self.backup_dir}")
|
||||
|
||||
def on_backup_file_select(self, event=None):
|
||||
pass
|
||||
|
||||
def on_backup_selected_clicked(self):
|
||||
selection = self.collection_listbox.curselection()
|
||||
if not selection:
|
||||
self.log_message("No collection selected for backup.")
|
||||
return
|
||||
|
||||
collection_name = self.collection_listbox.get(selection[0])
|
||||
fmt = self.backup_format_combo.get()
|
||||
self.run_in_async_loop(self.backup_collection, collection_name, fmt)
|
||||
|
||||
async def backup_collection(self, collection_name: str, fmt: str):
|
||||
try:
|
||||
cursor = self.db[collection_name].find({})
|
||||
docs = await cursor.to_list(length=None)
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
output_path = self.backup_dir / f"{collection_name}[{timestamp}].{fmt.lower()}"
|
||||
|
||||
if fmt == "JSON":
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(DataProcessor.to_json_compatible(docs), f, indent=2)
|
||||
elif fmt == "BSON":
|
||||
if not HAVE_BSON_STREAM:
|
||||
raise RuntimeError("BSON streaming not available in this environment.")
|
||||
with open(output_path, "wb") as f:
|
||||
for doc in docs:
|
||||
f.write(BSON.encode(doc))
|
||||
elif fmt == "CSV":
|
||||
rows = [DataProcessor.flatten_json(DataProcessor.to_json_compatible(doc)) for doc in docs]
|
||||
headers = sorted({key for row in rows for key in row.keys()})
|
||||
with open(output_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=headers)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
else:
|
||||
raise ValueError(f"Unsupported backup format: {fmt}")
|
||||
|
||||
self.log_message(f"Backed up {collection_name} to {output_path.name}")
|
||||
self.notebook.after(0, lambda: self.update_backup_files_listbox(collection_name))
|
||||
except Exception as e:
|
||||
self.log_message(f"Backup Error: {e}")
|
||||
|
||||
def on_restore_clicked(self):
|
||||
selection = self.collection_listbox.curselection()
|
||||
file_selection = self.backup_files_listbox.curselection()
|
||||
|
||||
if not selection:
|
||||
self.log_message("No collection selected for restore.")
|
||||
return
|
||||
if not file_selection:
|
||||
self.log_message("No backup file selected for restore.")
|
||||
return
|
||||
|
||||
collection_name = self.collection_listbox.get(selection[0])
|
||||
filename = self.backup_files_listbox.get(file_selection[0])
|
||||
|
||||
self.run_in_async_loop(self.restore_collection, collection_name, filename)
|
||||
|
||||
async def restore_collection(self, collection_name: str, filename: str):
|
||||
restore_path = self.backup_dir / filename
|
||||
if not restore_path.exists():
|
||||
self.log_message(f"Restore file not found: {restore_path}")
|
||||
return
|
||||
|
||||
try:
|
||||
ext = restore_path.suffix.lower()
|
||||
docs = []
|
||||
|
||||
if ext == ".json":
|
||||
with open(restore_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
docs = data if isinstance(data, list) else [data]
|
||||
|
||||
elif ext == ".bson":
|
||||
if not HAVE_BSON_STREAM:
|
||||
raise RuntimeError("BSON restore not supported in this environment.")
|
||||
with open(restore_path, "rb") as f:
|
||||
docs = list(decode_file_iter(f))
|
||||
|
||||
elif ext == ".csv":
|
||||
with open(restore_path, "r", encoding="utf-8", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
docs = [self._unflatten_csv_row(row) for row in reader]
|
||||
else:
|
||||
raise ValueError(f"Unsupported restore file type: {ext}")
|
||||
|
||||
normalized_docs = [self._normalize_doc_ids(doc) for doc in docs]
|
||||
if not normalized_docs:
|
||||
self.log_message("Restore file contained no documents.")
|
||||
return
|
||||
|
||||
await self.db[collection_name].delete_many({})
|
||||
await self.db[collection_name].insert_many(normalized_docs)
|
||||
self.log_message(f"Restored {len(normalized_docs)} documents into {collection_name}")
|
||||
except Exception as e:
|
||||
self.log_message(f"Restore Error: {e}")
|
||||
|
||||
def open_file_explorer(self):
|
||||
filepath = filedialog.askopenfilename(
|
||||
initialdir=str(self.backup_dir),
|
||||
title="Select a Backup File",
|
||||
filetypes=[
|
||||
("All supported", "*.json *.bson *.csv"),
|
||||
("JSON files", "*.json"),
|
||||
("BSON files", "*.bson"),
|
||||
("CSV files", "*.csv"),
|
||||
],
|
||||
)
|
||||
if not filepath:
|
||||
return
|
||||
|
||||
file_path_obj = Path(filepath)
|
||||
filename = file_path_obj.name
|
||||
|
||||
self.backup_files_listbox.selection_clear(0, END)
|
||||
existing = list(self.backup_files_listbox.get(0, END))
|
||||
if filename not in existing:
|
||||
self.backup_files_listbox.insert(END, filename)
|
||||
|
||||
index = list(self.backup_files_listbox.get(0, END)).index(filename)
|
||||
self.backup_files_listbox.selection_set(index)
|
||||
self.backup_files_listbox.see(index)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_doc_ids(doc: dict):
|
||||
if isinstance(doc, dict) and "_id" in doc and isinstance(doc["_id"], str) and ObjectId.is_valid(doc["_id"]):
|
||||
try:
|
||||
doc["_id"] = ObjectId(doc["_id"])
|
||||
except errors.InvalidId:
|
||||
pass
|
||||
return doc
|
||||
|
||||
@staticmethod
|
||||
def _unflatten_csv_row(row: dict) -> dict:
|
||||
root = {}
|
||||
|
||||
def set_path(container, parts, value):
|
||||
if not parts:
|
||||
return value
|
||||
|
||||
key = parts[0]
|
||||
is_index = key.isdigit()
|
||||
|
||||
if is_index:
|
||||
index = int(key)
|
||||
if not isinstance(container, list):
|
||||
container = []
|
||||
while len(container) <= index:
|
||||
container.append({})
|
||||
container[index] = set_path(container[index], parts[1:], value)
|
||||
return container
|
||||
|
||||
if not isinstance(container, dict):
|
||||
container = {}
|
||||
|
||||
container[key] = set_path(container.get(key, {}), parts[1:], value)
|
||||
return container
|
||||
|
||||
for k, v in row.items():
|
||||
if v in (None, ""):
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
except Exception:
|
||||
parsed = v
|
||||
root = set_path(root, k.split("."), parsed)
|
||||
|
||||
return root
|
||||
@@ -0,0 +1,468 @@
|
||||
# zmongo_manager.py
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tkinter import ttk, Tk, Listbox, Entry, Button, END, BOTH, LEFT, X
|
||||
from tkinter.scrolledtext import ScrolledText
|
||||
|
||||
from bson import json_util
|
||||
from dotenv import load_dotenv
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from pymongo import MongoClient
|
||||
|
||||
# Handle dynamic imports for the toolbag
|
||||
try:
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
from .zmongo_toolbag.safe_result import SafeResult
|
||||
except (ImportError, ValueError):
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
from .zmongo_toolbag.safe_result import SafeResult
|
||||
|
||||
# BSON Streaming support check
|
||||
try:
|
||||
from bson import BSON, decode_file_iter
|
||||
|
||||
HAVE_BSON_STREAM = True
|
||||
except ImportError:
|
||||
HAVE_BSON_STREAM = False
|
||||
|
||||
# --- Configuration ---
|
||||
load_dotenv(Path.home() / ".resources" / ".env")
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
MONGO_URI = os.getenv("MONGO_URI", "mongodb://127.0.0.1:27017")
|
||||
MONGO_DATABASE_NAME = os.getenv("MONGO_DATABASE_NAME", "test")
|
||||
MONGO_BACKUP_DIR_REL = os.getenv("MONGO_BACKUP_DIR", '.resources/mongo_backups')
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pager:
|
||||
limit: int = 100
|
||||
skip: int = 0
|
||||
|
||||
|
||||
class ZMongoManager(Tk):
|
||||
_instance = None
|
||||
_instance_lock = asyncio.Lock()
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop = None):
|
||||
super().__init__()
|
||||
self.zmongo = ZMongo()
|
||||
self.title("ZMongo System Manager")
|
||||
self.geometry("1400x900")
|
||||
|
||||
# Fallback to current loop if not provided
|
||||
self.loop = loop or asyncio.get_event_loop()
|
||||
self.db_name = MONGO_DATABASE_NAME
|
||||
self.backup_dir = Path.home() / MONGO_BACKUP_DIR_REL / MONGO_DATABASE_NAME
|
||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
self.async_client = AsyncIOMotorClient(MONGO_URI)
|
||||
self.db = self.async_client[self.db_name]
|
||||
self.sync_client = MongoClient(MONGO_URI)
|
||||
self.sync_db = self.sync_client[self.db_name]
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to connect to MongoDB: {e}")
|
||||
self.destroy()
|
||||
return
|
||||
|
||||
self.cv_pager = Pager()
|
||||
self.cv_ids_cache = []
|
||||
|
||||
self._create_widgets()
|
||||
self.run_periodic_updates()
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls):
|
||||
"""
|
||||
Async singleton accessor. Requires no arguments.
|
||||
Detects the running loop automatically.
|
||||
"""
|
||||
if cls._instance is not None:
|
||||
return cls._instance
|
||||
|
||||
async with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
# Get the loop currently running this coroutine
|
||||
loop = asyncio.get_running_loop()
|
||||
cls._instance = cls(loop)
|
||||
return cls._instance
|
||||
|
||||
def cv_refresh_docs_clicked(self):
|
||||
coll = self.cv_collection_entry.get().strip()
|
||||
if not coll:
|
||||
self.log_message("Please select a collection first.")
|
||||
return
|
||||
|
||||
raw_filter = self.cv_filter_entry.get().strip() or "{}"
|
||||
try:
|
||||
filt = json.loads(raw_filter)
|
||||
if not isinstance(filt, dict):
|
||||
self.log_message("Filter must be a JSON object.")
|
||||
return
|
||||
except json.JSONDecodeError as e:
|
||||
self.log_message(f"Invalid JSON filter: {e}")
|
||||
return
|
||||
|
||||
async def _fetch():
|
||||
cursor = self.db[coll].find(filt).limit(100)
|
||||
docs = await cursor.to_list(length=100)
|
||||
|
||||
rows = []
|
||||
for doc in docs:
|
||||
oid = doc.get("_id")
|
||||
oid_str = str(oid) if oid is not None else "<no _id>"
|
||||
|
||||
preview = (
|
||||
doc.get("name")
|
||||
or doc.get("title")
|
||||
or doc.get("username")
|
||||
or doc.get("email")
|
||||
or doc.get("metadata.title")
|
||||
or ""
|
||||
)
|
||||
|
||||
if not preview:
|
||||
flat = DataProcessor.flatten_json(DataProcessor.normalize_objectid(doc))
|
||||
preview_parts = []
|
||||
for key in ("name", "title", "username", "email", "metadata.title", "metadata.source_url"):
|
||||
val = flat.get(key)
|
||||
if val:
|
||||
preview_parts.append(f"{key}={val}")
|
||||
preview = " | ".join(preview_parts[:2])
|
||||
|
||||
label = oid_str if not preview else f"{oid_str} | {preview}"
|
||||
rows.append((label, oid))
|
||||
|
||||
return rows
|
||||
|
||||
def _on_done(f):
|
||||
try:
|
||||
pairs = f.result()
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self.log_message(f"Refresh failed for '{coll}': {e}"))
|
||||
return
|
||||
|
||||
def _update():
|
||||
self.cv_doc_listbox.delete(0, END)
|
||||
self.cv_ids_cache = pairs
|
||||
self.cv_json_text.delete("1.0", END)
|
||||
|
||||
for label, _ in pairs:
|
||||
self.cv_doc_listbox.insert(END, label)
|
||||
|
||||
self.log_message(f"Loaded {len(pairs)} record(s) from '{coll}'.")
|
||||
|
||||
self.after(0, _update)
|
||||
|
||||
self.run_in_async_loop(_fetch).add_done_callback(_on_done)
|
||||
|
||||
def cv_on_doc_select(self, event=None):
|
||||
idx = self.cv_doc_listbox.curselection()
|
||||
if not idx:
|
||||
return
|
||||
|
||||
coll = self.cv_collection_entry.get().strip()
|
||||
if not coll:
|
||||
self.log_message("No collection selected.")
|
||||
return
|
||||
|
||||
if idx[0] >= len(self.cv_ids_cache):
|
||||
self.log_message("Selected record index is out of range.")
|
||||
return
|
||||
|
||||
_, oid = self.cv_ids_cache[idx[0]]
|
||||
|
||||
async def _get():
|
||||
return await self.db[coll].find_one({"_id": oid})
|
||||
|
||||
def _on_done(f):
|
||||
try:
|
||||
doc = f.result()
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self.log_message(f"Document load failed for '{coll}': {e}"))
|
||||
return
|
||||
|
||||
def _show():
|
||||
self.cv_json_text.delete("1.0", END)
|
||||
if doc is None:
|
||||
self.cv_json_text.insert(END, "{}")
|
||||
self.log_message("Document not found.")
|
||||
return
|
||||
self.cv_json_text.insert(END, json_util.dumps(doc, indent=2))
|
||||
|
||||
self.after(0, _show)
|
||||
|
||||
self.run_in_async_loop(_get).add_done_callback(_on_done)
|
||||
|
||||
async def fetch_and_update_collections(self):
|
||||
names = sorted(await self.db.list_collection_names())
|
||||
|
||||
def _update():
|
||||
self.collection_listbox.delete(0, END)
|
||||
if hasattr(self, "cv_collection_listbox"):
|
||||
self.cv_collection_listbox.delete(0, END)
|
||||
|
||||
for n in names:
|
||||
self.collection_listbox.insert(END, n)
|
||||
if hasattr(self, "cv_collection_listbox"):
|
||||
self.cv_collection_listbox.insert(END, n)
|
||||
|
||||
self.after(0, _update)
|
||||
|
||||
# ---------- UI BUILD ----------
|
||||
def _create_widgets(self):
|
||||
self.notebook = ttk.Notebook(self)
|
||||
self.notebook.pack(expand=True, fill=BOTH, padx=10, pady=10)
|
||||
|
||||
tabs = {
|
||||
"info": ttk.Frame(self.notebook),
|
||||
"maint": ttk.Frame(self.notebook),
|
||||
"viewer": ttk.Frame(self.notebook),
|
||||
"system": ttk.Frame(self.notebook)
|
||||
}
|
||||
|
||||
self.notebook.add(tabs["info"], text='Database Info')
|
||||
self.notebook.add(tabs["maint"], text='Backup & Restore')
|
||||
self.notebook.add(tabs["viewer"], text='Collection Viewer / Editor')
|
||||
self.notebook.add(tabs["system"], text='System Runner')
|
||||
|
||||
self.db_info_text = ScrolledText(tabs["info"], wrap="word", font=("Courier New", 10))
|
||||
self.db_info_text.pack(expand=True, fill=BOTH, padx=5, pady=5)
|
||||
|
||||
self._build_maintenance_tab(tabs["maint"])
|
||||
self._build_collection_tab(tabs["viewer"])
|
||||
|
||||
def _build_maintenance_tab(self, parent):
|
||||
root = ttk.Frame(parent)
|
||||
root.pack(fill=BOTH, expand=True, padx=8, pady=8)
|
||||
root.grid_columnconfigure((1, 3), weight=1)
|
||||
root.grid_rowconfigure(5, weight=1)
|
||||
|
||||
# Collection List
|
||||
self.collection_listbox = Listbox(root, exportselection=False, height=12)
|
||||
self.collection_listbox.grid(row=1, column=0, sticky="nswe", padx=5)
|
||||
self.collection_listbox.bind('<<ListboxSelect>>', self.on_collection_select)
|
||||
|
||||
# Backup Files List
|
||||
self.backup_files_listbox = Listbox(root, exportselection=False, height=12)
|
||||
self.backup_files_listbox.grid(row=1, column=2, sticky="nswe", padx=5)
|
||||
self.backup_files_listbox.bind('<<ListboxSelect>>', self.on_backup_file_select)
|
||||
|
||||
# Entries
|
||||
self.selected_collection_entry = Entry(root, state='readonly')
|
||||
self.selected_collection_entry.grid(row=1, column=1, sticky="we", padx=5)
|
||||
self.selected_backup_entry = Entry(root, state='readonly')
|
||||
self.selected_backup_entry.grid(row=1, column=3, sticky="we", padx=5)
|
||||
|
||||
# Actions
|
||||
actions = ttk.Frame(root)
|
||||
actions.grid(row=2, column=1, columnspan=3, sticky="we", pady=10)
|
||||
self.backup_format_combo = ttk.Combobox(actions, state='readonly', values=["JSON", "BSON", "CSV"], width=8)
|
||||
self.backup_format_combo.current(0)
|
||||
self.backup_format_combo.pack(side=LEFT, padx=5)
|
||||
|
||||
Button(actions, text='Backup Selected', command=self.on_backup_selected_clicked).pack(side=LEFT, padx=5)
|
||||
Button(actions, text='Restore Selected', command=self.on_restore_clicked).pack(side=LEFT, padx=5)
|
||||
Button(actions, text='Browse...', command=self.open_file_explorer).pack(side=LEFT, padx=5)
|
||||
|
||||
self.message_text = ScrolledText(root, height=8, state='disabled')
|
||||
self.message_text.grid(row=5, column=0, columnspan=4, sticky="nswe", padx=5)
|
||||
|
||||
def _build_collection_tab(self, parent):
|
||||
root = ttk.Frame(parent)
|
||||
root.pack(fill=BOTH, expand=True, padx=8, pady=8)
|
||||
root.grid_columnconfigure(1, weight=1)
|
||||
root.grid_rowconfigure(2, weight=1)
|
||||
|
||||
# --- Left side: collection list + document list ---
|
||||
left = ttk.Frame(root)
|
||||
left.grid(row=0, column=0, rowspan=3, sticky="ns", padx=5, pady=5)
|
||||
left.grid_rowconfigure(1, weight=1)
|
||||
left.grid_rowconfigure(3, weight=1)
|
||||
|
||||
ttk.Label(left, text="Collections").grid(row=0, column=0, sticky="w")
|
||||
self.cv_collection_listbox = Listbox(left, exportselection=False, height=12, width=35)
|
||||
self.cv_collection_listbox.grid(row=1, column=0, sticky="nswe", pady=(0, 8))
|
||||
self.cv_collection_listbox.bind("<<ListboxSelect>>", self.cv_on_collection_select)
|
||||
|
||||
ttk.Label(left, text="Records").grid(row=2, column=0, sticky="w")
|
||||
self.cv_doc_listbox = Listbox(left, exportselection=False, width=45)
|
||||
self.cv_doc_listbox.grid(row=3, column=0, sticky="nswe")
|
||||
self.cv_doc_listbox.bind("<<ListboxSelect>>", self.cv_on_doc_select)
|
||||
|
||||
# --- Top controls ---
|
||||
ttk.Label(root, text="Selected Collection").grid(row=0, column=1, sticky="w", padx=5)
|
||||
self.cv_collection_entry = Entry(root)
|
||||
self.cv_collection_entry.grid(row=0, column=1, sticky="we", padx=5)
|
||||
|
||||
ttk.Label(root, text="Filter JSON").grid(row=1, column=1, sticky="w", padx=5)
|
||||
self.cv_filter_entry = Entry(root)
|
||||
self.cv_filter_entry.insert(0, "{}")
|
||||
self.cv_filter_entry.grid(row=1, column=1, sticky="we", padx=5)
|
||||
|
||||
btn_frame = ttk.Frame(root)
|
||||
btn_frame.grid(row=0, column=2, rowspan=2, sticky="ns", padx=5)
|
||||
Button(btn_frame, text="Refresh Records", command=self.cv_refresh_docs_clicked).pack(fill=X, pady=2)
|
||||
Button(btn_frame, text="Apply $set", command=self.on_apply_dotkey_value_clicked).pack(fill=X, pady=2)
|
||||
|
||||
# --- JSON viewer ---
|
||||
self.cv_json_text = ScrolledText(root, font=("Courier New", 10))
|
||||
self.cv_json_text.grid(row=2, column=1, columnspan=2, sticky="nswe", padx=5, pady=5)
|
||||
|
||||
# ---------- Logic ----------
|
||||
def run_in_async_loop(self, async_func, *args, **kwargs):
|
||||
return asyncio.run_coroutine_threadsafe(async_func(*args, **kwargs), self.loop)
|
||||
|
||||
def _handle_future_result(self, future):
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
self.log_message(f"Async Error: {e}")
|
||||
|
||||
def log_message(self, message):
|
||||
def _append():
|
||||
self.message_text.config(state='normal')
|
||||
self.message_text.insert("end", f"{datetime.now().strftime('%H:%M:%S')} - {message}\n")
|
||||
self.message_text.config(state='disabled')
|
||||
self.message_text.see("end")
|
||||
|
||||
self.after(0, _append)
|
||||
|
||||
async def backup_collection(self, collection_name, fmt):
|
||||
try:
|
||||
docs = await self.db[collection_name].find({}).to_list(length=None)
|
||||
ts = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
path = self.backup_dir / f"{collection_name}[{ts}].{fmt.lower()}"
|
||||
|
||||
if fmt == "JSON":
|
||||
path.write_text(json_util.dumps(docs, indent=2))
|
||||
elif fmt == "BSON" and HAVE_BSON_STREAM:
|
||||
with open(path, 'wb') as f:
|
||||
for d in docs: f.write(BSON.encode(d))
|
||||
elif fmt == "CSV":
|
||||
flat = [DataProcessor.flatten_json(d) for d in docs]
|
||||
if flat:
|
||||
keys = sorted(list(set().union(*(d.keys() for d in flat))))
|
||||
with open(path, 'w', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=keys)
|
||||
writer.writeheader()
|
||||
writer.writerows(flat)
|
||||
|
||||
self.log_message(f"Backup saved: {path.name}")
|
||||
self.after(0, lambda: self.update_backup_files_listbox(collection_name))
|
||||
except Exception as e:
|
||||
self.log_message(f"Backup failed: {e}")
|
||||
|
||||
# ---------- Event Handlers ----------
|
||||
def cv_on_collection_select(self, event=None):
|
||||
idx = self.cv_collection_listbox.curselection()
|
||||
if not idx:
|
||||
return
|
||||
|
||||
name = self.cv_collection_listbox.get(idx[0])
|
||||
|
||||
self.cv_collection_entry.delete(0, END)
|
||||
self.cv_collection_entry.insert(0, name)
|
||||
|
||||
# Keep backup tab collection selection synchronized
|
||||
self.selected_collection_entry.config(state='normal')
|
||||
self.selected_collection_entry.delete(0, END)
|
||||
self.selected_collection_entry.insert(0, name)
|
||||
self.selected_collection_entry.config(state='readonly')
|
||||
|
||||
self._select_listbox_value(self.collection_listbox, name)
|
||||
self.update_backup_files_listbox(name)
|
||||
self.cv_refresh_docs_clicked()
|
||||
|
||||
def on_collection_select(self, event=None):
|
||||
idx = self.collection_listbox.curselection()
|
||||
if not idx:
|
||||
return
|
||||
|
||||
name = self.collection_listbox.get(idx[0])
|
||||
|
||||
self.selected_collection_entry.config(state='normal')
|
||||
self.selected_collection_entry.delete(0, END)
|
||||
self.selected_collection_entry.insert(0, name)
|
||||
self.selected_collection_entry.config(state='readonly')
|
||||
|
||||
# Keep collection viewer synchronized
|
||||
self.cv_collection_entry.delete(0, END)
|
||||
self.cv_collection_entry.insert(0, name)
|
||||
|
||||
if hasattr(self, "cv_collection_listbox"):
|
||||
self._select_listbox_value(self.cv_collection_listbox, name)
|
||||
|
||||
self.update_backup_files_listbox(name)
|
||||
self.cv_refresh_docs_clicked()
|
||||
|
||||
def on_backup_selected_clicked(self):
|
||||
name = self.selected_collection_entry.get()
|
||||
fmt = self.backup_format_combo.get()
|
||||
if name: self.run_in_async_loop(self.backup_collection, name, fmt)
|
||||
|
||||
def _select_listbox_value(self, listbox, value):
|
||||
try:
|
||||
items = listbox.get(0, END)
|
||||
if value in items:
|
||||
idx = items.index(value)
|
||||
listbox.selection_clear(0, END)
|
||||
listbox.selection_set(idx)
|
||||
listbox.activate(idx)
|
||||
listbox.see(idx)
|
||||
except Exception as e:
|
||||
self.log_message(f"List selection sync failed: {e}")
|
||||
|
||||
# --- Cleanup and Periodic ---
|
||||
def update_backup_files_listbox(self, name):
|
||||
self.backup_files_listbox.delete(0, END)
|
||||
if self.backup_dir.exists():
|
||||
for f in self.backup_dir.glob(f"{name}[*].*"):
|
||||
self.backup_files_listbox.insert(END, f.name)
|
||||
|
||||
def run_periodic_updates(self):
|
||||
self.run_in_async_loop(self.fetch_and_update_collections)
|
||||
self.after(60000, self.run_periodic_updates)
|
||||
|
||||
def on_closing(self):
|
||||
self.async_client.close()
|
||||
self.sync_client.close()
|
||||
type(self)._instance = None
|
||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||
self.destroy()
|
||||
|
||||
# Stub methods for missing logic from snippet
|
||||
def on_restore_clicked(self):
|
||||
pass
|
||||
|
||||
def open_file_explorer(self):
|
||||
pass
|
||||
|
||||
def on_backup_file_select(self, event):
|
||||
pass
|
||||
|
||||
def on_apply_dotkey_value_clicked(self):
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
loop = asyncio.new_event_loop()
|
||||
threading.Thread(target=loop.run_forever, daemon=True).start()
|
||||
|
||||
app = ZMongoManager(loop)
|
||||
app.protocol("WM_DELETE_WINDOW", app.on_closing)
|
||||
app.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
import json
|
||||
|
||||
from .zmongo_manager import ZMongoManager
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
|
||||
class ZMongoOperationsNode:
|
||||
"""Handles unique data ingestion and maintenance tasks."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"collection": ("STRING", {"default": "legal_codex"}),
|
||||
"data_json": ("STRING", {"multiline": True}),
|
||||
"operation_type": (["Add Unique (No Update)", "Standard Insert"],),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT")
|
||||
RETURN_NAMES = ("result_json", "count")
|
||||
FUNCTION = "execute_op"
|
||||
CATEGORY = "ZMongo/Operations"
|
||||
|
||||
def execute_op(self, collection, data_json, operation_type):
|
||||
manager = ZMongoManager.get_instance()
|
||||
data = json.loads(data_json)
|
||||
records = data if isinstance(data, list) else [data]
|
||||
|
||||
if operation_type == "Add Unique (No Update)":
|
||||
# Implements 'check-before-insert' via async bulk_write
|
||||
res = manager.client.run_sync(manager.client.bulk_write_async, collection, records)
|
||||
return (DataProcessor.to_json(res.data), res.data.get("inserted_count", 0))
|
||||
|
||||
res = manager.client.insert_many(collection, records)
|
||||
return (DataProcessor.to_json(res.data), len(res.data.get("inserted_ids", [])))
|
||||
@@ -0,0 +1,28 @@
|
||||
# 1. SPECIALIZED MODEL NODE IMPORTS 🧠
|
||||
# Using absolute imports ensures these are found during standalone tests.
|
||||
from ..nodes.model_loader.zmongo_model_compatibility_display_node import ZMongoModelCompatibilityDisplayNode
|
||||
from ..nodes.model_loader.zmongo_model_introspector_node import ZMongoModelIntrospectorNode
|
||||
from ..nodes.model_loader.zmongo_universal_model_selector_node import ZMongoUniversalModelSelectorNode
|
||||
from ..nodes.model_loader.zmongo_universal_model_loader_node import ZMongoUniversalModelLoaderNode
|
||||
from ..nodes.model_loader.zmongo_universal_adapter_node import ZMongoUniversalModelAdapterNode
|
||||
from ..nodes.model_loader.zmongo_builtin_loader_adapter_node import ZMongoBuiltInLoaderAdapterNode
|
||||
|
||||
# 2. SUB-PACKAGE MAPPINGS 📋
|
||||
# Defining these here allows the parent 'nodes' folder to simply import and merge them.
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZMongoUniversalModelSelectorNode": ZMongoUniversalModelSelectorNode,
|
||||
"ZMongoModelIntrospectorNode": ZMongoModelIntrospectorNode,
|
||||
"ZMongoModelCompatibilityDisplayNode": ZMongoModelCompatibilityDisplayNode,
|
||||
"ZMongoUniversalModelLoaderNode": ZMongoUniversalModelLoaderNode,
|
||||
"ZMongoUniversalModelAdapterNode": ZMongoUniversalModelAdapterNode,
|
||||
"ZMongoBuiltInLoaderAdapterNode": ZMongoBuiltInLoaderAdapterNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZMongoUniversalModelSelectorNode": "🧩 ZMongo Universal Model Selector",
|
||||
"ZMongoModelIntrospectorNode": "🧠 ZMongo Model Introspector",
|
||||
"ZMongoModelCompatibilityDisplayNode": "✅ ZMongo Model Compatibility Display",
|
||||
"ZMongoUniversalModelLoaderNode": "📦 ZMongo Universal Model Loader",
|
||||
"ZMongoUniversalModelAdapterNode": "🔌 ZMongo Universal Model Adapter",
|
||||
"ZMongoBuiltInLoaderAdapterNode": "⚙️ ZMongo Built-In Loader Adapter",
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# SPECIALIZED MODEL NODE IMPORTS 🤖
|
||||
from .zmongo_model_compatibility_display_node import ZMongoModelCompatibilityDisplayNode
|
||||
from .zmongo_model_introspector_node import ZMongoModelIntrospectorNode
|
||||
from .zmongo_universal_model_selector_node import ZMongoUniversalModelSelectorNode
|
||||
from .zmongo_universal_model_loader_node import ZMongoUniversalModelLoaderNode
|
||||
from .zmongo_universal_adapter_node import ZMongoUniversalModelAdapterNode
|
||||
from .zmongo_builtin_loader_adapter_node import ZMongoBuiltInLoaderAdapterNode
|
||||
|
||||
# EXPORTING THE SUITE 📦
|
||||
__all__ = [
|
||||
"ZMongoModelCompatibilityDisplayNode",
|
||||
"ZMongoModelIntrospectorNode",
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoUniversalModelLoaderNode",
|
||||
"ZMongoUniversalModelAdapterNode",
|
||||
"ZMongoBuiltInLoaderAdapterNode",
|
||||
]
|
||||
@@ -0,0 +1,437 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import folder_paths
|
||||
from nodes import (
|
||||
CLIPLoader,
|
||||
CLIPVisionLoader,
|
||||
CheckpointLoaderSimple,
|
||||
ControlNetLoader,
|
||||
StyleModelLoader,
|
||||
UNETLoader,
|
||||
VAELoader,
|
||||
)
|
||||
from comfy_extras.nodes_upscale_model import UpscaleModelLoader
|
||||
|
||||
class ZMongoBuiltInLoaderAdapterNode:
|
||||
"""
|
||||
Universal adapter that reuses ComfyUI built-in loaders wherever possible.
|
||||
"""
|
||||
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "adapt"
|
||||
|
||||
RETURN_TYPES = (
|
||||
"MODEL",
|
||||
"CLIP",
|
||||
"VAE",
|
||||
"MODEL",
|
||||
"CONTROL_NET",
|
||||
"CLIP_VISION",
|
||||
"UPSCALE_MODEL",
|
||||
"STYLE_MODEL",
|
||||
"STRING",
|
||||
"BOOLEAN",
|
||||
"STRING",
|
||||
"STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"model",
|
||||
"clip",
|
||||
"vae",
|
||||
"unet_model",
|
||||
"control_net",
|
||||
"clip_vision",
|
||||
"upscale_model",
|
||||
"style_model",
|
||||
"status_text",
|
||||
"is_compatible",
|
||||
"model_type",
|
||||
"built_in_loader_name",
|
||||
)
|
||||
|
||||
MODEL_TYPE_TO_FOLDER = {
|
||||
"checkpoint": "checkpoints",
|
||||
"vae": "vae",
|
||||
"clip": "clip",
|
||||
"text_encoder": "text_encoders",
|
||||
"unet": "diffusion_models",
|
||||
"diffusion": "diffusion_models",
|
||||
"controlnet": "controlnet",
|
||||
"clip_vision": "clip_vision",
|
||||
"upscaler": "upscale_models",
|
||||
"style_model": "style_models",
|
||||
}
|
||||
|
||||
BUILTIN_BY_MODEL_TYPE = {
|
||||
"checkpoint": "CheckpointLoaderSimple",
|
||||
"vae": "VAELoader",
|
||||
"clip": "CLIPLoader",
|
||||
"text_encoder": "CLIPLoader",
|
||||
"unet": "UNETLoader",
|
||||
"diffusion": "UNETLoader",
|
||||
"controlnet": "ControlNetLoader",
|
||||
"clip_vision": "CLIPVisionLoader",
|
||||
"upscaler": "UpscaleModelLoader",
|
||||
"style_model": "StyleModelLoader",
|
||||
}
|
||||
|
||||
SUPPORTED_MODEL_TYPES = set(BUILTIN_BY_MODEL_TYPE.keys())
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"full_path": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
"model_info_json": ("STRING", {"forceInput": True}),
|
||||
"clip_type": (
|
||||
[
|
||||
"stable_diffusion",
|
||||
"stable_cascade",
|
||||
"sd3",
|
||||
"stable_audio",
|
||||
"mochi",
|
||||
"ltxv",
|
||||
"pixart",
|
||||
"cosmos",
|
||||
"lumina2",
|
||||
"wan",
|
||||
"hidream",
|
||||
],
|
||||
{"default": "stable_diffusion"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
full_path: str,
|
||||
model_info_json: str = "",
|
||||
clip_type: str = "stable_diffusion",
|
||||
):
|
||||
return full_path, model_info_json, clip_type
|
||||
|
||||
@staticmethod
|
||||
def _none_outputs() -> Tuple[Any, Any, Any, Any, Any, Any, Any]:
|
||||
return None, None, None, None, None, None, None
|
||||
|
||||
@staticmethod
|
||||
def _safe_parse_model_info(model_info_json: str) -> Dict[str, Any]:
|
||||
if not model_info_json:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(model_info_json)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path(full_path: str) -> Optional[Path]:
|
||||
if not full_path:
|
||||
return None
|
||||
try:
|
||||
return Path(full_path).expanduser().resolve()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _infer_model_type_from_path(path: Path) -> str:
|
||||
parts = [part.lower() for part in path.parts]
|
||||
|
||||
if "checkpoints" in parts:
|
||||
return "checkpoint"
|
||||
if "vae" in parts:
|
||||
return "vae"
|
||||
if "clip_vision" in parts:
|
||||
return "clip_vision"
|
||||
if "clip" in parts:
|
||||
return "clip"
|
||||
if "text_encoders" in parts:
|
||||
return "text_encoder"
|
||||
if "controlnet" in parts:
|
||||
return "controlnet"
|
||||
if "upscale_models" in parts:
|
||||
return "upscaler"
|
||||
if "style_models" in parts:
|
||||
return "style_model"
|
||||
if "diffusion_models" in parts:
|
||||
return "diffusion"
|
||||
if "unet" in parts:
|
||||
return "unet"
|
||||
|
||||
ext = path.suffix.lower()
|
||||
if ext == ".ckpt":
|
||||
return "checkpoint"
|
||||
if ext in {".pt", ".pth", ".bin", ".safetensors"}:
|
||||
return "unknown"
|
||||
|
||||
return "unknown"
|
||||
|
||||
@classmethod
|
||||
def _resolve_model_type(cls, full_path: Path, model_info: Dict[str, Any]) -> str:
|
||||
model_type = str(model_info.get("model_type", "")).strip()
|
||||
if model_type:
|
||||
return model_type
|
||||
return cls._infer_model_type_from_path(full_path)
|
||||
|
||||
@classmethod
|
||||
def _relative_name_for_folder_type(cls, full_path: Path, folder_type: str) -> str:
|
||||
normalized = full_path.resolve()
|
||||
available = folder_paths.get_filename_list(folder_type)
|
||||
|
||||
for entry in available:
|
||||
candidate = Path(folder_paths.get_full_path(folder_type, entry)).resolve()
|
||||
if candidate == normalized:
|
||||
return entry.replace("\\", "/")
|
||||
|
||||
raise ValueError(
|
||||
f"Selected file is not registered under Comfy models folder type "
|
||||
f"'{folder_type}': {full_path}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _call_first_available(instance: Any, methods_with_kwargs):
|
||||
last_error = None
|
||||
|
||||
for method_name, kwargs in methods_with_kwargs:
|
||||
method = getattr(instance, method_name, None)
|
||||
if method is None:
|
||||
continue
|
||||
try:
|
||||
return method(**kwargs)
|
||||
except TypeError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
|
||||
raise AttributeError(
|
||||
f"No compatible loader method found on {instance.__class__.__name__}"
|
||||
)
|
||||
|
||||
def _load_checkpoint(self, relative_name: str):
|
||||
loader = CheckpointLoaderSimple()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_checkpoint", {"ckpt_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 3:
|
||||
raise ValueError("CheckpointLoaderSimple returned an unexpected result.")
|
||||
return result[0], result[1], result[2]
|
||||
|
||||
def _load_vae(self, relative_name: str):
|
||||
loader = VAELoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_vae", {"vae_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("VAELoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_clip(self, relative_name: str, clip_type: str):
|
||||
loader = CLIPLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_clip", {"clip_name": relative_name, "type": clip_type}),
|
||||
("load_clip", {"clip_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("CLIPLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_unet(self, relative_name: str):
|
||||
loader = UNETLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_unet", {"unet_name": relative_name}),
|
||||
("load_model", {"unet_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("UNETLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_controlnet(self, relative_name: str):
|
||||
loader = ControlNetLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_controlnet", {"control_net_name": relative_name}),
|
||||
("load_controlnet", {"controlnet_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("ControlNetLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_clip_vision(self, relative_name: str):
|
||||
loader = CLIPVisionLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_clip", {"clip_name": relative_name}),
|
||||
("load_clip_vision", {"clip_name": relative_name}),
|
||||
("load_clip_vision", {"clip_vision_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("CLIPVisionLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_upscale_model(self, relative_name: str):
|
||||
loader = UpscaleModelLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_model", {"model_name": relative_name}),
|
||||
("load_model", {"upscale_model": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("UpscaleModelLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def _load_style_model(self, relative_name: str):
|
||||
loader = StyleModelLoader()
|
||||
result = self._call_first_available(
|
||||
loader,
|
||||
[
|
||||
("load_style_model", {"style_model_name": relative_name}),
|
||||
("load_model", {"style_model_name": relative_name}),
|
||||
("load_model", {"model_name": relative_name}),
|
||||
],
|
||||
)
|
||||
if not isinstance(result, tuple) or len(result) < 1:
|
||||
raise ValueError("StyleModelLoader returned an unexpected result.")
|
||||
return result[0]
|
||||
|
||||
def adapt(
|
||||
self,
|
||||
full_path: str,
|
||||
model_info_json: str = "",
|
||||
clip_type: str = "stable_diffusion",
|
||||
):
|
||||
model_obj, clip_obj, vae_obj, unet_obj, controlnet_obj, clipvision_obj, upscale_obj = self._none_outputs()
|
||||
style_obj = None
|
||||
|
||||
path = self._normalize_path(full_path)
|
||||
if path is None or not path.exists() or not path.is_file():
|
||||
return (
|
||||
model_obj,
|
||||
clip_obj,
|
||||
vae_obj,
|
||||
unet_obj,
|
||||
controlnet_obj,
|
||||
clipvision_obj,
|
||||
upscale_obj,
|
||||
style_obj,
|
||||
"Invalid or missing model path.",
|
||||
False,
|
||||
"unknown",
|
||||
"",
|
||||
)
|
||||
|
||||
model_info = self._safe_parse_model_info(model_info_json)
|
||||
model_type = self._resolve_model_type(path, model_info)
|
||||
built_in_loader_name = self.BUILTIN_BY_MODEL_TYPE.get(model_type, "")
|
||||
|
||||
if model_type not in self.SUPPORTED_MODEL_TYPES:
|
||||
return (
|
||||
model_obj,
|
||||
clip_obj,
|
||||
vae_obj,
|
||||
unet_obj,
|
||||
controlnet_obj,
|
||||
clipvision_obj,
|
||||
upscale_obj,
|
||||
style_obj,
|
||||
(
|
||||
f"No compatible built-in Comfy loader for model type "
|
||||
f"'{model_type}'. Path: {path}"
|
||||
),
|
||||
False,
|
||||
model_type,
|
||||
built_in_loader_name,
|
||||
)
|
||||
|
||||
folder_type = self.MODEL_TYPE_TO_FOLDER[model_type]
|
||||
|
||||
try:
|
||||
relative_name = self._relative_name_for_folder_type(path, folder_type)
|
||||
|
||||
if model_type == "checkpoint":
|
||||
model_obj, clip_obj, vae_obj = self._load_checkpoint(relative_name)
|
||||
elif model_type == "vae":
|
||||
vae_obj = self._load_vae(relative_name)
|
||||
elif model_type in {"clip", "text_encoder"}:
|
||||
clip_obj = self._load_clip(relative_name, clip_type)
|
||||
elif model_type in {"unet", "diffusion"}:
|
||||
unet_obj = self._load_unet(relative_name)
|
||||
elif model_type == "controlnet":
|
||||
controlnet_obj = self._load_controlnet(relative_name)
|
||||
elif model_type == "clip_vision":
|
||||
clipvision_obj = self._load_clip_vision(relative_name)
|
||||
elif model_type == "upscaler":
|
||||
upscale_obj = self._load_upscale_model(relative_name)
|
||||
elif model_type == "style_model":
|
||||
style_obj = self._load_style_model(relative_name)
|
||||
|
||||
status_text = (
|
||||
f"Loaded with built-in loader '{built_in_loader_name}'.\n"
|
||||
f"Model type: {model_type}\n"
|
||||
f"Folder type: {folder_type}\n"
|
||||
f"Relative name: {relative_name}\n"
|
||||
f"Path: {path}"
|
||||
)
|
||||
|
||||
return (
|
||||
model_obj,
|
||||
clip_obj,
|
||||
vae_obj,
|
||||
unet_obj,
|
||||
controlnet_obj,
|
||||
clipvision_obj,
|
||||
upscale_obj,
|
||||
style_obj,
|
||||
status_text,
|
||||
True,
|
||||
model_type,
|
||||
built_in_loader_name,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
return (
|
||||
model_obj,
|
||||
clip_obj,
|
||||
vae_obj,
|
||||
unet_obj,
|
||||
controlnet_obj,
|
||||
clipvision_obj,
|
||||
upscale_obj,
|
||||
style_obj,
|
||||
(
|
||||
f"Built-in loader adapter failed.\n"
|
||||
f"Model type: {model_type}\n"
|
||||
f"Built-in loader: {built_in_loader_name}\n"
|
||||
f"Path: {path}\n"
|
||||
f"Error: {exc}"
|
||||
),
|
||||
False,
|
||||
model_type,
|
||||
built_in_loader_name,
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
class ZMongoModelCompatibilityDisplayNode:
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "describe_compatibility"
|
||||
RETURN_TYPES = (
|
||||
"BOOLEAN", # is_compatible
|
||||
"STRING", # status_text
|
||||
"STRING", # compatible_targets
|
||||
"STRING", # compatible_sources
|
||||
"STRING", # selected_model_type
|
||||
"STRING", # selected_architecture
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"is_compatible",
|
||||
"status_text",
|
||||
"compatible_targets",
|
||||
"compatible_sources",
|
||||
"selected_model_type",
|
||||
"selected_architecture",
|
||||
)
|
||||
|
||||
EXPECTED_NODE_TO_MODEL_TYPES: Dict[str, List[str]] = {
|
||||
"ZMongoLLMLoaderNode": ["llm"],
|
||||
"ZMongoPromptRunnerNode": ["llm"],
|
||||
"ZMongoChatModelNode": ["llm"],
|
||||
|
||||
"ZMongoEmbeddingLoaderNode": ["embedding", "text_encoder", "onnx_model"],
|
||||
"ZMongoVectorIndexNode": ["embedding"],
|
||||
"ZMongoEmbeddingInferenceNode": ["embedding", "text_encoder", "onnx_model"],
|
||||
|
||||
"ZMongoCLIPLoaderNode": ["clip", "onnx_model"],
|
||||
"ZMongoCLIPVisionLoaderNode": ["clip_vision", "onnx_model"],
|
||||
"ZMongoVisionInferenceNode": ["clip", "clip_vision", "onnx_model", "vision"],
|
||||
|
||||
"ZMongoCheckpointLoaderNode": ["checkpoint", "torch_model"],
|
||||
"ZMongoTorchLoaderNode": ["torch_model", "checkpoint"],
|
||||
"ZMongoTorchInferenceNode": ["torch_model", "checkpoint", "diffusion"],
|
||||
|
||||
"ZMongoVAELoaderNode": ["vae"],
|
||||
"ZMongoDiffusionLoaderNode": ["diffusion", "checkpoint"],
|
||||
"ZMongoControlNetLoaderNode": ["controlnet"],
|
||||
"ZMongoUpscaleLoaderNode": ["upscaler"],
|
||||
"ZMongoLatentUpscaleLoaderNode": ["latent_upscaler"],
|
||||
|
||||
"ZMongoAudioLoaderNode": ["audio_model"],
|
||||
"ZMongoAudioInferenceNode": ["audio_model"],
|
||||
"ZMongoSpeechLoaderNode": ["speech_model"],
|
||||
"ZMongoSpeechInferenceNode": ["speech_model"],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_info_json": ("STRING", {"forceInput": True}),
|
||||
"expected_target_node": (sorted(list(cls.EXPECTED_NODE_TO_MODEL_TYPES.keys())),),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, model_info_json: str, expected_target_node: str):
|
||||
return model_info_json, expected_target_node
|
||||
|
||||
@staticmethod
|
||||
def _safe_load_model_info(model_info_json: str) -> dict:
|
||||
try:
|
||||
value = json.loads(model_info_json)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _csv(items: List[str]) -> str:
|
||||
return ", ".join(items) if items else "None"
|
||||
|
||||
def describe_compatibility(self, model_info_json: str, expected_target_node: str):
|
||||
model_info = self._safe_load_model_info(model_info_json)
|
||||
|
||||
selected_model_type = str(model_info.get("model_type", "unknown"))
|
||||
selected_architecture = str(model_info.get("architecture_family", "unknown"))
|
||||
compatible_targets = list(model_info.get("can_connect_to", []))
|
||||
compatible_sources = list(model_info.get("can_receive_from", []))
|
||||
|
||||
expected_types = self.EXPECTED_NODE_TO_MODEL_TYPES.get(expected_target_node, [])
|
||||
is_compatible = selected_model_type in expected_types
|
||||
|
||||
if is_compatible:
|
||||
status_text = (
|
||||
f"Compatible: model_type '{selected_model_type}' can connect to "
|
||||
f"'{expected_target_node}'."
|
||||
)
|
||||
else:
|
||||
status_text = (
|
||||
f"Incompatible: model_type '{selected_model_type}' does not match "
|
||||
f"'{expected_target_node}'. Expected one of: {self._csv(expected_types)}."
|
||||
)
|
||||
|
||||
status_text += (
|
||||
f"\nSelected architecture: {selected_architecture}"
|
||||
f"\nCan connect to: {self._csv(compatible_targets)}"
|
||||
f"\nCan receive from: {self._csv(compatible_sources)}"
|
||||
)
|
||||
|
||||
return (
|
||||
is_compatible,
|
||||
status_text,
|
||||
self._csv(compatible_targets),
|
||||
self._csv(compatible_sources),
|
||||
selected_model_type,
|
||||
selected_architecture,
|
||||
)
|
||||
@@ -0,0 +1,395 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
class ZMongoModelIntrospectorNode:
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "inspect_model"
|
||||
RETURN_TYPES = (
|
||||
"STRING", # model_info_json
|
||||
"STRING", # summary_text
|
||||
"STRING", # model_type
|
||||
"STRING", # architecture_family
|
||||
"STRING", # can_connect_to
|
||||
"STRING", # can_receive_from
|
||||
"STRING", # full_path
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"model_info_json",
|
||||
"summary_text",
|
||||
"model_type",
|
||||
"architecture_family",
|
||||
"can_connect_to",
|
||||
"can_receive_from",
|
||||
"full_path",
|
||||
)
|
||||
|
||||
FOLDER_TYPE_HINTS: Dict[str, Tuple[str, str]] = {
|
||||
"llm": ("llm", "llama_cpp"),
|
||||
"embeddings": ("embedding", "embedding_loader"),
|
||||
"encoders": ("embedding", "embedding_loader"),
|
||||
"clip": ("clip", "clip_loader"),
|
||||
"clip_vision": ("clip_vision", "clip_vision_loader"),
|
||||
"vae": ("vae", "vae_loader"),
|
||||
"unet": ("unet", "torch_loader"),
|
||||
"checkpoints": ("checkpoint", "checkpoint_loader"),
|
||||
"diffusion_models": ("diffusion", "diffusion_loader"),
|
||||
"loras": ("lora", "lora_loader"),
|
||||
"controlnet": ("controlnet", "controlnet_loader"),
|
||||
"upscale_models": ("upscaler", "upscale_loader"),
|
||||
"latent_upscale_models": ("latent_upscaler", "latent_upscale_loader"),
|
||||
"onnx": ("onnx_model", "onnxruntime"),
|
||||
"vision": ("vision", "vision_loader"),
|
||||
"audio": ("audio_model", "audio_loader"),
|
||||
"speech_models": ("speech_model", "audio_loader"),
|
||||
"text_encoders": ("text_encoder", "text_encoder_loader"),
|
||||
}
|
||||
|
||||
EXTENSION_HINTS: Dict[str, Tuple[str, str]] = {
|
||||
".gguf": ("llm", "llama_cpp"),
|
||||
".onnx": ("onnx_model", "onnxruntime"),
|
||||
".safetensors": ("torch_model", "torch_loader"),
|
||||
".ckpt": ("checkpoint", "checkpoint_loader"),
|
||||
".pt": ("torch_model", "torch_loader"),
|
||||
".pth": ("torch_model", "torch_loader"),
|
||||
".bin": ("binary_model", "custom_loader"),
|
||||
".json": ("config", "config_parser"),
|
||||
".yaml": ("config", "config_parser"),
|
||||
".yml": ("config", "config_parser"),
|
||||
".model": ("generic_model", "custom_loader"),
|
||||
".index": ("index", "index_loader"),
|
||||
}
|
||||
|
||||
ARCHITECTURE_HINTS = {
|
||||
"bge": "bge",
|
||||
"e5": "e5",
|
||||
"gte": "gte",
|
||||
"clip": "clip",
|
||||
"siglip": "siglip",
|
||||
"llama": "llama",
|
||||
"mistral": "mistral",
|
||||
"qwen": "qwen",
|
||||
"gemma": "gemma",
|
||||
"bert": "bert",
|
||||
"t5": "t5",
|
||||
"whisper": "whisper",
|
||||
}
|
||||
|
||||
COMPATIBILITY_MAP: Dict[str, Dict[str, List[str]]] = {
|
||||
"llm": {
|
||||
"can_connect_to": [
|
||||
"ZMongoLLMLoaderNode",
|
||||
"ZMongoPromptRunnerNode",
|
||||
"ZMongoChatModelNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"embedding": {
|
||||
"can_connect_to": [
|
||||
"ZMongoEmbeddingLoaderNode",
|
||||
"ZMongoVectorIndexNode",
|
||||
"ZMongoEmbeddingInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"clip": {
|
||||
"can_connect_to": [
|
||||
"ZMongoCLIPLoaderNode",
|
||||
"ZMongoVisionInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"clip_vision": {
|
||||
"can_connect_to": [
|
||||
"ZMongoCLIPVisionLoaderNode",
|
||||
"ZMongoVisionInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"onnx_model": {
|
||||
"can_connect_to": [
|
||||
"ZMongoONNXLoaderNode",
|
||||
"ZMongoVisionInferenceNode",
|
||||
"ZMongoEmbeddingInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"checkpoint": {
|
||||
"can_connect_to": [
|
||||
"ZMongoCheckpointLoaderNode",
|
||||
"ZMongoTorchInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"torch_model": {
|
||||
"can_connect_to": [
|
||||
"ZMongoTorchLoaderNode",
|
||||
"ZMongoTorchInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"text_encoder": {
|
||||
"can_connect_to": [
|
||||
"ZMongoTextEncoderLoaderNode",
|
||||
"ZMongoEmbeddingInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"vae": {
|
||||
"can_connect_to": [
|
||||
"ZMongoVAELoaderNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"diffusion": {
|
||||
"can_connect_to": [
|
||||
"ZMongoDiffusionLoaderNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"controlnet": {
|
||||
"can_connect_to": [
|
||||
"ZMongoControlNetLoaderNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"upscaler": {
|
||||
"can_connect_to": [
|
||||
"ZMongoUpscaleLoaderNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"latent_upscaler": {
|
||||
"can_connect_to": [
|
||||
"ZMongoLatentUpscaleLoaderNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"audio_model": {
|
||||
"can_connect_to": [
|
||||
"ZMongoAudioLoaderNode",
|
||||
"ZMongoAudioInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"speech_model": {
|
||||
"can_connect_to": [
|
||||
"ZMongoSpeechLoaderNode",
|
||||
"ZMongoSpeechInferenceNode",
|
||||
],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
"unknown": {
|
||||
"can_connect_to": [],
|
||||
"can_receive_from": [
|
||||
"ZMongoUniversalModelSelectorNode",
|
||||
"ZMongoModelTypeRouterNode",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"full_path": ("STRING", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, full_path: str):
|
||||
return full_path
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path(full_path: str) -> Path | None:
|
||||
if not full_path:
|
||||
return None
|
||||
try:
|
||||
return Path(full_path).expanduser().resolve()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _folder_hint(cls, path: Path) -> Tuple[str, str] | None:
|
||||
for part in reversed([p.lower() for p in path.parts]):
|
||||
if part in cls.FOLDER_TYPE_HINTS:
|
||||
return cls.FOLDER_TYPE_HINTS[part]
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extension_hint(cls, path: Path) -> Tuple[str, str] | None:
|
||||
return cls.EXTENSION_HINTS.get(path.suffix.lower())
|
||||
|
||||
@classmethod
|
||||
def _architecture_family(cls, path: Path) -> str:
|
||||
haystack = " ".join([part.lower() for part in path.parts])
|
||||
for token, family in cls.ARCHITECTURE_HINTS.items():
|
||||
if token in haystack:
|
||||
return family
|
||||
return "unknown"
|
||||
|
||||
@classmethod
|
||||
def _classify(cls, path: Path) -> Tuple[str, str]:
|
||||
ext = path.suffix.lower()
|
||||
folder_hint = cls._folder_hint(path)
|
||||
ext_hint = cls._extension_hint(path)
|
||||
|
||||
if ext == ".gguf":
|
||||
return "llm", "llama_cpp"
|
||||
if ext == ".onnx":
|
||||
return "onnx_model", "onnxruntime"
|
||||
if ext in {".json", ".yaml", ".yml"}:
|
||||
if folder_hint:
|
||||
return f"config_for_{folder_hint[0]}", folder_hint[1]
|
||||
return "config", "config_parser"
|
||||
if ext in {".safetensors", ".pt", ".pth", ".ckpt", ".bin", ".model"}:
|
||||
if folder_hint:
|
||||
return folder_hint
|
||||
if ext_hint:
|
||||
return ext_hint
|
||||
if folder_hint:
|
||||
return folder_hint
|
||||
if ext_hint:
|
||||
return ext_hint
|
||||
return "unknown", "manual_loader_required"
|
||||
|
||||
@staticmethod
|
||||
def _find_sidecar_files(path: Path) -> List[str]:
|
||||
candidates = [
|
||||
"config.json",
|
||||
"tokenizer.json",
|
||||
"tokenizer_config.json",
|
||||
"model_index.json",
|
||||
"params.json",
|
||||
"metadata.json",
|
||||
"config.yaml",
|
||||
"config.yml",
|
||||
]
|
||||
found: List[str] = []
|
||||
for candidate in candidates:
|
||||
candidate_path = path.parent / candidate
|
||||
if candidate_path.exists() and candidate_path.is_file():
|
||||
found.append(str(candidate_path))
|
||||
return found
|
||||
|
||||
@staticmethod
|
||||
def _format_list(items: List[str]) -> str:
|
||||
return ", ".join(items) if items else "None"
|
||||
|
||||
def inspect_model(self, full_path: str):
|
||||
path = self._normalize_path(full_path)
|
||||
if path is None or not path.exists() or not path.is_file():
|
||||
model_info = {
|
||||
"full_path": "",
|
||||
"model_type": "unknown",
|
||||
"loader_hint": "invalid_path",
|
||||
"architecture_family": "unknown",
|
||||
"filename": "",
|
||||
"extension": "",
|
||||
"parent_folder": "",
|
||||
"sidecar_files": [],
|
||||
"can_connect_to": [],
|
||||
"can_receive_from": [],
|
||||
}
|
||||
summary = "Invalid or missing model path."
|
||||
return (
|
||||
json.dumps(model_info, indent=2),
|
||||
summary,
|
||||
"unknown",
|
||||
"unknown",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
|
||||
model_type, loader_hint = self._classify(path)
|
||||
architecture_family = self._architecture_family(path)
|
||||
compatibility = self.COMPATIBILITY_MAP.get(model_type, self.COMPATIBILITY_MAP["unknown"])
|
||||
can_connect_to = compatibility["can_connect_to"]
|
||||
can_receive_from = compatibility["can_receive_from"]
|
||||
sidecar_files = self._find_sidecar_files(path)
|
||||
|
||||
model_info = {
|
||||
"full_path": str(path),
|
||||
"model_type": model_type,
|
||||
"loader_hint": loader_hint,
|
||||
"architecture_family": architecture_family,
|
||||
"filename": path.name,
|
||||
"stem": path.stem,
|
||||
"extension": path.suffix.lower(),
|
||||
"parent_folder": path.parent.name,
|
||||
"sidecar_files": sidecar_files,
|
||||
"can_connect_to": can_connect_to,
|
||||
"can_receive_from": can_receive_from,
|
||||
}
|
||||
|
||||
summary_lines = [
|
||||
f"Model: {path.name}",
|
||||
f"Type: {model_type}",
|
||||
f"Architecture: {architecture_family}",
|
||||
f"Loader hint: {loader_hint}",
|
||||
f"Extension: {path.suffix.lower()}",
|
||||
f"Folder: {path.parent.name}",
|
||||
f"Can connect to: {self._format_list(can_connect_to)}",
|
||||
f"Can receive from: {self._format_list(can_receive_from)}",
|
||||
f"Sidecar files: {self._format_list(sidecar_files)}",
|
||||
]
|
||||
summary_text = "\n".join(summary_lines)
|
||||
|
||||
return (
|
||||
json.dumps(model_info, indent=2),
|
||||
summary_text,
|
||||
model_type,
|
||||
architecture_family,
|
||||
self._format_list(can_connect_to),
|
||||
self._format_list(can_receive_from),
|
||||
str(path),
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
class ZMongoUniversalModelAdapterNode:
|
||||
"""
|
||||
Universal adapter that consumes ZMongo model metadata and exposes a fixed
|
||||
set of outputs. It uses smart-check compatibility metadata to decide which
|
||||
outputs are active.
|
||||
|
||||
Important:
|
||||
- Output socket types remain statically declared for ComfyUI.
|
||||
- Inactive outputs are returned as None and should be treated as disabled.
|
||||
- A companion JS extension can visually dim inactive sockets/widgets.
|
||||
"""
|
||||
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "adapt"
|
||||
|
||||
RETURN_TYPES = (
|
||||
"MODEL",
|
||||
"CLIP",
|
||||
"VAE",
|
||||
"STRING",
|
||||
"STRING",
|
||||
"BOOLEAN",
|
||||
"STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"model",
|
||||
"clip",
|
||||
"vae",
|
||||
"status_text",
|
||||
"active_outputs_json",
|
||||
"is_compatible",
|
||||
"model_type",
|
||||
)
|
||||
|
||||
# Which outputs are allowed for each detected model type.
|
||||
OUTPUTS_BY_MODEL_TYPE: Dict[str, List[str]] = {
|
||||
"checkpoint": ["model", "clip", "vae"],
|
||||
"torch_model": ["model"],
|
||||
"diffusion": ["model"],
|
||||
"controlnet": ["model"],
|
||||
"upscaler": ["model"],
|
||||
"latent_upscaler": ["model"],
|
||||
"unet": ["model"],
|
||||
"clip": ["clip"],
|
||||
"clip_vision": ["clip"],
|
||||
"vae": ["vae"],
|
||||
"llm": [],
|
||||
"embedding": [],
|
||||
"text_encoder": [],
|
||||
"onnx_model": [],
|
||||
"audio_model": [],
|
||||
"speech_model": [],
|
||||
"unknown": [],
|
||||
}
|
||||
|
||||
# Which target families the selected model is allowed to drive.
|
||||
TARGETS_BY_MODEL_TYPE: Dict[str, List[str]] = {
|
||||
"checkpoint": ["MODEL", "CLIP", "VAE"],
|
||||
"torch_model": ["MODEL"],
|
||||
"diffusion": ["MODEL"],
|
||||
"controlnet": ["MODEL"],
|
||||
"upscaler": ["MODEL"],
|
||||
"latent_upscaler": ["MODEL"],
|
||||
"unet": ["MODEL"],
|
||||
"clip": ["CLIP"],
|
||||
"clip_vision": ["CLIP"],
|
||||
"vae": ["VAE"],
|
||||
"llm": [],
|
||||
"embedding": [],
|
||||
"text_encoder": [],
|
||||
"onnx_model": [],
|
||||
"audio_model": [],
|
||||
"speech_model": [],
|
||||
"unknown": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"zmongo_model_json": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
"expected_output_family": (
|
||||
["AUTO", "MODEL", "CLIP", "VAE"],
|
||||
{"default": "AUTO"},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, zmongo_model_json: str, expected_output_family: str = "AUTO", unique_id=None):
|
||||
return (zmongo_model_json, expected_output_family, unique_id)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, input_types):
|
||||
# Keep backend validation permissive here; real validation happens after
|
||||
# parsing the metadata payload because the smart-check logic depends on
|
||||
# model metadata, not just the declared socket type.
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _safe_parse(payload: str) -> Dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _compute_active_outputs(
|
||||
cls,
|
||||
model_type: str,
|
||||
expected_output_family: str,
|
||||
) -> Tuple[List[str], bool, str]:
|
||||
allowed = cls.OUTPUTS_BY_MODEL_TYPE.get(model_type, [])
|
||||
allowed_families = cls.TARGETS_BY_MODEL_TYPE.get(model_type, [])
|
||||
|
||||
if expected_output_family == "AUTO":
|
||||
return allowed, bool(allowed), "AUTO mode"
|
||||
|
||||
is_compatible = expected_output_family in allowed_families
|
||||
if not is_compatible:
|
||||
return [], False, (
|
||||
f"Model type '{model_type}' is not compatible with requested "
|
||||
f"output family '{expected_output_family}'. Allowed: "
|
||||
f"{', '.join(allowed_families) if allowed_families else 'None'}"
|
||||
)
|
||||
|
||||
# Keep only the outputs that map to the requested family.
|
||||
family_to_outputs = {
|
||||
"MODEL": ["model"],
|
||||
"CLIP": ["clip"],
|
||||
"VAE": ["vae"],
|
||||
}
|
||||
requested_outputs = family_to_outputs.get(expected_output_family, [])
|
||||
active = [name for name in allowed if name in requested_outputs]
|
||||
return active, True, f"Compatible with requested family '{expected_output_family}'"
|
||||
|
||||
@staticmethod
|
||||
def _status_text(
|
||||
model_type: str,
|
||||
architecture: str,
|
||||
full_path: str,
|
||||
active_outputs: List[str],
|
||||
compatible: bool,
|
||||
reason: str,
|
||||
) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
f"Model type: {model_type}",
|
||||
f"Architecture: {architecture}",
|
||||
f"Compatible: {compatible}",
|
||||
f"Active outputs: {', '.join(active_outputs) if active_outputs else 'None'}",
|
||||
f"Reason: {reason}",
|
||||
f"Path: {full_path}",
|
||||
]
|
||||
)
|
||||
|
||||
def adapt(
|
||||
self,
|
||||
zmongo_model_json: str,
|
||||
expected_output_family: str = "AUTO",
|
||||
unique_id: str | None = None,
|
||||
):
|
||||
del unique_id
|
||||
|
||||
payload = self._safe_parse(zmongo_model_json)
|
||||
model_type = str(payload.get("model_type", "unknown"))
|
||||
architecture = str(payload.get("architecture_family", "unknown"))
|
||||
full_path = str(payload.get("full_path", ""))
|
||||
|
||||
active_outputs, is_compatible, reason = self._compute_active_outputs(
|
||||
model_type=model_type,
|
||||
expected_output_family=expected_output_family,
|
||||
)
|
||||
|
||||
# Deliberately conservative:
|
||||
# We do not fabricate real Comfy MODEL/CLIP/VAE objects here.
|
||||
# Those should be created by specialized loader/adapter nodes.
|
||||
model_obj = None
|
||||
clip_obj = None
|
||||
vae_obj = None
|
||||
|
||||
status_text = self._status_text(
|
||||
model_type=model_type,
|
||||
architecture=architecture,
|
||||
full_path=full_path,
|
||||
active_outputs=active_outputs,
|
||||
compatible=is_compatible,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
active_outputs_json = json.dumps(
|
||||
{
|
||||
"active_outputs": active_outputs,
|
||||
"expected_output_family": expected_output_family,
|
||||
"model_type": model_type,
|
||||
"architecture_family": architecture,
|
||||
"compatible": is_compatible,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
return (
|
||||
model_obj if "model" in active_outputs else None,
|
||||
clip_obj if "clip" in active_outputs else None,
|
||||
vae_obj if "vae" in active_outputs else None,
|
||||
status_text,
|
||||
active_outputs_json,
|
||||
is_compatible,
|
||||
model_type,
|
||||
)
|
||||
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
class ZMongoUniversalModelLoaderNode:
|
||||
"""
|
||||
Universal model loader facade for ZMongo.
|
||||
|
||||
This node does not try to fully instantiate every possible model backend.
|
||||
Instead, it:
|
||||
|
||||
1. Accepts a selected model path.
|
||||
2. Classifies the model.
|
||||
3. Returns a universal internal payload (JSON string).
|
||||
4. Activates only the outputs that make sense for the selected model.
|
||||
5. Exposes status / compatibility information for downstream nodes.
|
||||
|
||||
Important:
|
||||
- Comfy output socket types are static.
|
||||
- So this node declares a fixed set of outputs and populates only the valid ones.
|
||||
- Core Comfy outputs such as MODEL / CLIP / VAE are only populated when the
|
||||
selected file appears compatible with those families.
|
||||
"""
|
||||
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "load_model"
|
||||
|
||||
RETURN_TYPES = (
|
||||
"STRING", # zmongo_model_json
|
||||
"MODEL", # model
|
||||
"CLIP", # clip
|
||||
"VAE", # vae
|
||||
"STRING", # model_info_json
|
||||
"STRING", # status_text
|
||||
"BOOLEAN", # is_valid
|
||||
"STRING", # model_type
|
||||
"STRING", # allowed_outputs
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"zmongo_model_json",
|
||||
"model",
|
||||
"clip",
|
||||
"vae",
|
||||
"model_info_json",
|
||||
"status_text",
|
||||
"is_valid",
|
||||
"model_type",
|
||||
"allowed_outputs",
|
||||
)
|
||||
|
||||
FOLDER_TYPE_HINTS: Dict[str, Tuple[str, str]] = {
|
||||
"llm": ("llm", "llama_cpp"),
|
||||
"embeddings": ("embedding", "embedding_loader"),
|
||||
"encoders": ("embedding", "embedding_loader"),
|
||||
"clip": ("clip", "clip_loader"),
|
||||
"clip_vision": ("clip_vision", "clip_vision_loader"),
|
||||
"vae": ("vae", "vae_loader"),
|
||||
"unet": ("unet", "torch_loader"),
|
||||
"checkpoints": ("checkpoint", "checkpoint_loader"),
|
||||
"diffusion_models": ("diffusion", "diffusion_loader"),
|
||||
"loras": ("lora", "lora_loader"),
|
||||
"controlnet": ("controlnet", "controlnet_loader"),
|
||||
"upscale_models": ("upscaler", "upscale_loader"),
|
||||
"latent_upscale_models": ("latent_upscaler", "latent_upscale_loader"),
|
||||
"onnx": ("onnx_model", "onnxruntime"),
|
||||
"vision": ("vision", "vision_loader"),
|
||||
"audio": ("audio_model", "audio_loader"),
|
||||
"speech_models": ("speech_model", "audio_loader"),
|
||||
"text_encoders": ("text_encoder", "text_encoder_loader"),
|
||||
}
|
||||
|
||||
EXTENSION_HINTS: Dict[str, Tuple[str, str]] = {
|
||||
".gguf": ("llm", "llama_cpp"),
|
||||
".onnx": ("onnx_model", "onnxruntime"),
|
||||
".safetensors": ("torch_model", "torch_loader"),
|
||||
".ckpt": ("checkpoint", "checkpoint_loader"),
|
||||
".pt": ("torch_model", "torch_loader"),
|
||||
".pth": ("torch_model", "torch_loader"),
|
||||
".bin": ("binary_model", "custom_loader"),
|
||||
".json": ("config", "config_parser"),
|
||||
".yaml": ("config", "config_parser"),
|
||||
".yml": ("config", "config_parser"),
|
||||
".model": ("generic_model", "custom_loader"),
|
||||
".index": ("index", "index_loader"),
|
||||
}
|
||||
|
||||
ARCHITECTURE_HINTS = {
|
||||
"bge": "bge",
|
||||
"e5": "e5",
|
||||
"gte": "gte",
|
||||
"clip": "clip",
|
||||
"siglip": "siglip",
|
||||
"llama": "llama",
|
||||
"mistral": "mistral",
|
||||
"qwen": "qwen",
|
||||
"gemma": "gemma",
|
||||
"bert": "bert",
|
||||
"t5": "t5",
|
||||
"whisper": "whisper",
|
||||
"sd": "stable_diffusion",
|
||||
"flux": "flux",
|
||||
}
|
||||
|
||||
MODEL_OUTPUT_COMPATIBILITY: Dict[str, List[str]] = {
|
||||
"llm": ["zmongo_model_json"],
|
||||
"embedding": ["zmongo_model_json"],
|
||||
"text_encoder": ["zmongo_model_json"],
|
||||
"onnx_model": ["zmongo_model_json"],
|
||||
"audio_model": ["zmongo_model_json"],
|
||||
"speech_model": ["zmongo_model_json"],
|
||||
"clip": ["zmongo_model_json", "clip"],
|
||||
"clip_vision": ["zmongo_model_json", "clip"],
|
||||
"vae": ["zmongo_model_json", "vae"],
|
||||
"checkpoint": ["zmongo_model_json", "model", "clip", "vae"],
|
||||
"torch_model": ["zmongo_model_json", "model"],
|
||||
"diffusion": ["zmongo_model_json", "model"],
|
||||
"controlnet": ["zmongo_model_json", "model"],
|
||||
"upscaler": ["zmongo_model_json", "model"],
|
||||
"latent_upscaler": ["zmongo_model_json", "model"],
|
||||
"unet": ["zmongo_model_json", "model"],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"full_path": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
"model_info_json": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, full_path: str, model_info_json: str = ""):
|
||||
return full_path, model_info_json
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path(full_path: str) -> Optional[Path]:
|
||||
if not full_path:
|
||||
return None
|
||||
try:
|
||||
return Path(full_path).expanduser().resolve()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _folder_hint(cls, path: Path) -> Optional[Tuple[str, str]]:
|
||||
for part in reversed([p.lower() for p in path.parts]):
|
||||
if part in cls.FOLDER_TYPE_HINTS:
|
||||
return cls.FOLDER_TYPE_HINTS[part]
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extension_hint(cls, path: Path) -> Optional[Tuple[str, str]]:
|
||||
return cls.EXTENSION_HINTS.get(path.suffix.lower())
|
||||
|
||||
@classmethod
|
||||
def _architecture_family(cls, path: Path) -> str:
|
||||
haystack = " ".join([part.lower() for part in path.parts])
|
||||
for token, family in cls.ARCHITECTURE_HINTS.items():
|
||||
if token in haystack:
|
||||
return family
|
||||
return "unknown"
|
||||
|
||||
@classmethod
|
||||
def _classify(cls, path: Path) -> Tuple[str, str]:
|
||||
ext = path.suffix.lower()
|
||||
folder_hint = cls._folder_hint(path)
|
||||
ext_hint = cls._extension_hint(path)
|
||||
|
||||
if ext == ".gguf":
|
||||
return "llm", "llama_cpp"
|
||||
if ext == ".onnx":
|
||||
return "onnx_model", "onnxruntime"
|
||||
if ext in {".json", ".yaml", ".yml"}:
|
||||
if folder_hint:
|
||||
return f"config_for_{folder_hint[0]}", folder_hint[1]
|
||||
return "config", "config_parser"
|
||||
if ext in {".safetensors", ".pt", ".pth", ".ckpt", ".bin", ".model"}:
|
||||
if folder_hint:
|
||||
return folder_hint
|
||||
if ext_hint:
|
||||
return ext_hint
|
||||
if folder_hint:
|
||||
return folder_hint
|
||||
if ext_hint:
|
||||
return ext_hint
|
||||
return "unknown", "manual_loader_required"
|
||||
|
||||
@staticmethod
|
||||
def _find_sidecar_files(path: Path) -> List[str]:
|
||||
candidates = [
|
||||
"config.json",
|
||||
"tokenizer.json",
|
||||
"tokenizer_config.json",
|
||||
"model_index.json",
|
||||
"params.json",
|
||||
"metadata.json",
|
||||
"config.yaml",
|
||||
"config.yml",
|
||||
]
|
||||
found: List[str] = []
|
||||
for candidate in candidates:
|
||||
candidate_path = path.parent / candidate
|
||||
if candidate_path.exists() and candidate_path.is_file():
|
||||
found.append(str(candidate_path))
|
||||
return found
|
||||
|
||||
@staticmethod
|
||||
def _try_parse_model_info(model_info_json: str) -> Dict[str, Any]:
|
||||
if not model_info_json:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(model_info_json)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _build_model_info(cls, path: Path) -> Dict[str, Any]:
|
||||
model_type, loader_hint = cls._classify(path)
|
||||
architecture_family = cls._architecture_family(path)
|
||||
allowed_outputs = cls.MODEL_OUTPUT_COMPATIBILITY.get(model_type, ["zmongo_model_json"])
|
||||
|
||||
return {
|
||||
"full_path": str(path),
|
||||
"filename": path.name,
|
||||
"stem": path.stem,
|
||||
"extension": path.suffix.lower(),
|
||||
"parent_folder": path.parent.name,
|
||||
"model_type": model_type,
|
||||
"loader_hint": loader_hint,
|
||||
"architecture_family": architecture_family,
|
||||
"sidecar_files": cls._find_sidecar_files(path),
|
||||
"allowed_outputs": allowed_outputs,
|
||||
"exists": path.exists(),
|
||||
"is_file": path.is_file(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _make_status(model_info: Dict[str, Any]) -> str:
|
||||
allowed_outputs = model_info.get("allowed_outputs", [])
|
||||
return "\n".join(
|
||||
[
|
||||
f"Loaded: {model_info.get('filename', '')}",
|
||||
f"Type: {model_info.get('model_type', 'unknown')}",
|
||||
f"Architecture: {model_info.get('architecture_family', 'unknown')}",
|
||||
f"Loader hint: {model_info.get('loader_hint', 'unknown')}",
|
||||
f"Allowed outputs: {', '.join(allowed_outputs) if allowed_outputs else 'None'}",
|
||||
f"Path: {model_info.get('full_path', '')}",
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _empty_core_outputs() -> Tuple[Any, Any, Any]:
|
||||
return None, None, None
|
||||
|
||||
def _build_zmongo_model_payload(self, model_info: Dict[str, Any]) -> str:
|
||||
payload = {
|
||||
"kind": "zmongo_model",
|
||||
"full_path": model_info.get("full_path", ""),
|
||||
"model_type": model_info.get("model_type", "unknown"),
|
||||
"architecture_family": model_info.get("architecture_family", "unknown"),
|
||||
"loader_hint": model_info.get("loader_hint", "unknown"),
|
||||
"allowed_outputs": model_info.get("allowed_outputs", []),
|
||||
"sidecar_files": model_info.get("sidecar_files", []),
|
||||
}
|
||||
return json.dumps(payload, indent=2)
|
||||
|
||||
def _activate_core_outputs(
|
||||
self,
|
||||
model_info: Dict[str, Any],
|
||||
) -> Tuple[Any, Any, Any]:
|
||||
"""
|
||||
Returns placeholders for MODEL / CLIP / VAE-compatible cases.
|
||||
|
||||
Note:
|
||||
This node intentionally does not instantiate arbitrary backend objects
|
||||
for every model family. For now:
|
||||
- checkpoint-like families can mark MODEL/CLIP/VAE as logically enabled
|
||||
- actual object creation should be delegated to adapter/loader nodes later
|
||||
"""
|
||||
model_type = model_info.get("model_type", "unknown")
|
||||
allowed_outputs = set(model_info.get("allowed_outputs", []))
|
||||
|
||||
model_obj = None
|
||||
clip_obj = None
|
||||
vae_obj = None
|
||||
|
||||
# Deliberately conservative:
|
||||
# We only advertise activation through metadata/status here.
|
||||
# Returning real core objects should be done in a dedicated adapter node.
|
||||
if model_type == "checkpoint":
|
||||
model_obj = None
|
||||
clip_obj = None
|
||||
vae_obj = None
|
||||
elif "model" in allowed_outputs:
|
||||
model_obj = None
|
||||
elif "clip" in allowed_outputs:
|
||||
clip_obj = None
|
||||
elif "vae" in allowed_outputs:
|
||||
vae_obj = None
|
||||
|
||||
return model_obj, clip_obj, vae_obj
|
||||
|
||||
def load_model(self, full_path: str, model_info_json: str = ""):
|
||||
parsed_path = self._normalize_path(full_path)
|
||||
if parsed_path is None or not parsed_path.exists() or not parsed_path.is_file():
|
||||
empty_payload = json.dumps(
|
||||
{
|
||||
"kind": "zmongo_model",
|
||||
"full_path": "",
|
||||
"model_type": "unknown",
|
||||
"architecture_family": "unknown",
|
||||
"loader_hint": "invalid_path",
|
||||
"allowed_outputs": [],
|
||||
"sidecar_files": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
empty_info = json.dumps(
|
||||
{
|
||||
"full_path": "",
|
||||
"model_type": "unknown",
|
||||
"loader_hint": "invalid_path",
|
||||
"architecture_family": "unknown",
|
||||
"allowed_outputs": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return (
|
||||
empty_payload,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
empty_info,
|
||||
"Invalid or missing model path.",
|
||||
False,
|
||||
"unknown",
|
||||
"",
|
||||
)
|
||||
|
||||
provided_info = self._try_parse_model_info(model_info_json)
|
||||
if provided_info and provided_info.get("full_path") == str(parsed_path):
|
||||
model_info = provided_info
|
||||
model_info.setdefault("allowed_outputs", self.MODEL_OUTPUT_COMPATIBILITY.get(
|
||||
model_info.get("model_type", "unknown"),
|
||||
["zmongo_model_json"],
|
||||
))
|
||||
model_info.setdefault("architecture_family", self._architecture_family(parsed_path))
|
||||
model_info.setdefault("loader_hint", self._classify(parsed_path)[1])
|
||||
model_info.setdefault("sidecar_files", self._find_sidecar_files(parsed_path))
|
||||
else:
|
||||
model_info = self._build_model_info(parsed_path)
|
||||
|
||||
zmongo_model_json = self._build_zmongo_model_payload(model_info)
|
||||
model_obj, clip_obj, vae_obj = self._activate_core_outputs(model_info)
|
||||
|
||||
status_text = self._make_status(model_info)
|
||||
is_valid = model_info.get("model_type", "unknown") != "unknown"
|
||||
model_type = str(model_info.get("model_type", "unknown"))
|
||||
allowed_outputs = ", ".join(model_info.get("allowed_outputs", []))
|
||||
|
||||
return (
|
||||
zmongo_model_json,
|
||||
model_obj,
|
||||
clip_obj,
|
||||
vae_obj,
|
||||
json.dumps(model_info, indent=2),
|
||||
status_text,
|
||||
is_valid,
|
||||
model_type,
|
||||
allowed_outputs,
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Tuple
|
||||
|
||||
import folder_paths
|
||||
|
||||
|
||||
class ZMongoUniversalModelSelectorNode:
|
||||
"""
|
||||
Universal recursive model selector for files stored under:
|
||||
|
||||
ComfyUI/models/zmongo/
|
||||
|
||||
It does not load the model itself. It only returns normalized path data so
|
||||
downstream ZMongo nodes can decide how to use the selected file.
|
||||
"""
|
||||
|
||||
CATEGORY = "ZMongo/Models"
|
||||
FUNCTION = "select_model"
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = ("full_path", "relative_path", "filename", "stem", "extension")
|
||||
|
||||
# File extensions commonly used by Comfy / ML / embeddings / LLM ecosystems.
|
||||
ALLOWED_EXTENSIONS = {
|
||||
".bin",
|
||||
".ckpt",
|
||||
".gguf",
|
||||
".index",
|
||||
".json",
|
||||
".mar",
|
||||
".model",
|
||||
".onnx",
|
||||
".pt",
|
||||
".pth",
|
||||
".py",
|
||||
".safetensors",
|
||||
".t7",
|
||||
".tar",
|
||||
".uf2",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _zmongo_root(cls) -> Path:
|
||||
return Path(folder_paths.models_dir) / "checkpoints"
|
||||
|
||||
@classmethod
|
||||
def _iter_model_files(cls, root: Path) -> Iterable[Path]:
|
||||
if not root.exists() or not root.is_dir():
|
||||
return []
|
||||
|
||||
files: List[Path] = []
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.suffix.lower() not in cls.ALLOWED_EXTENSIONS:
|
||||
continue
|
||||
files.append(path)
|
||||
|
||||
files.sort(key=lambda p: str(p.relative_to(root)).lower())
|
||||
return files
|
||||
|
||||
@classmethod
|
||||
def _relative_choices(cls) -> List[str]:
|
||||
root = cls._zmongo_root()
|
||||
files = cls._iter_model_files(root)
|
||||
|
||||
choices = [str(path.relative_to(root)).replace("\\", "/") for path in files]
|
||||
|
||||
if not choices:
|
||||
choices = ["<no models found in models/zmongo>"]
|
||||
|
||||
return choices
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_relative_path": (cls._relative_choices(),),
|
||||
},
|
||||
"optional": {
|
||||
"refresh_token": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 999999,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, model_relative_path: str, refresh_token: int = 0):
|
||||
"""
|
||||
Re-executes when selection changes or when refresh_token changes.
|
||||
This does not rebuild the combo live in the UI, but it helps rerun nodes
|
||||
after you change the filesystem and bump the token.
|
||||
"""
|
||||
return (model_relative_path, refresh_token)
|
||||
|
||||
@staticmethod
|
||||
def _empty_result() -> Tuple[str, str, str, str, str]:
|
||||
return ("", "", "", "", "")
|
||||
|
||||
def select_model(
|
||||
self,
|
||||
model_relative_path: str,
|
||||
refresh_token: int = 0,
|
||||
) -> Tuple[str, str, str, str, str]:
|
||||
del refresh_token # execution-only knob for Comfy cache / rerun behavior
|
||||
|
||||
root = self._zmongo_root()
|
||||
|
||||
if model_relative_path == "<no models found in models/zmongo>":
|
||||
return self._empty_result()
|
||||
|
||||
full_path = (root / model_relative_path).resolve()
|
||||
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
return self._empty_result()
|
||||
|
||||
return (
|
||||
str(full_path),
|
||||
model_relative_path.replace("\\", "/"),
|
||||
full_path.name,
|
||||
full_path.stem,
|
||||
full_path.suffix.lower(),
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from ..zmongo_toolbag.zmongo import ZMongo
|
||||
from ..zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
class ZMongoFieldSelectorNode:
|
||||
CATEGORY = "ZMongo"
|
||||
FUNCTION = "select_field"
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"collection_name",
|
||||
"selected_field_name",
|
||||
"selected_field_index",
|
||||
"selected_field_value",
|
||||
"flattened_field_names_json",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_zmongo(cls) -> ZMongo:
|
||||
return ZMongo()
|
||||
|
||||
@staticmethod
|
||||
def _clamp_index(value: int, max_len: int) -> int:
|
||||
if max_len <= 0:
|
||||
return 0
|
||||
return max(0, min(int(value), max_len - 1))
|
||||
|
||||
@staticmethod
|
||||
def _stringify_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (dict, list, tuple, bool, int, float)):
|
||||
try:
|
||||
return json.dumps(
|
||||
DataProcessor.to_json_compatible(value),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
except Exception:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_collection_names(cls) -> List[str]:
|
||||
zmongo = None
|
||||
try:
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.run_sync(zmongo.list_collections_async)
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load collection names: %s",
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
data = result.data or {}
|
||||
if not isinstance(data, dict):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = data.get("collections", [])
|
||||
if not isinstance(names, list):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = [str(name) for name in names if str(name).strip()]
|
||||
return names or ["<no_collections_found>"]
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error loading collection names: %s", exc)
|
||||
return ["<mongo_error>"]
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_sample_document(cls, collection_name: str) -> Dict[str, Any]:
|
||||
zmongo = None
|
||||
try:
|
||||
if not collection_name or collection_name.startswith("<"):
|
||||
return {}
|
||||
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.find_one(collection_name, {})
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load sample document for '%s': %s",
|
||||
collection_name,
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return {}
|
||||
|
||||
doc = result.original() if hasattr(result, "original") else result.data
|
||||
return doc if isinstance(doc, dict) else {}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error loading sample document for collection '%s': %s",
|
||||
collection_name,
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
|
||||
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_flattened_field_names(cls, collection_name: str) -> List[str]:
|
||||
try:
|
||||
doc = cls.get_sample_document(collection_name)
|
||||
if not doc:
|
||||
return []
|
||||
|
||||
flattened = DataProcessor.flatten_json(doc)
|
||||
if not isinstance(flattened, dict):
|
||||
return []
|
||||
|
||||
return sorted(str(k) for k in flattened.keys())
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error flattening fields for collection '%s': %s",
|
||||
collection_name,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
collections = cls.get_collection_names()
|
||||
default_collection = collections[0] if collections else "<no_collections_found>"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"collection_name": (collections, {"default": default_collection}),
|
||||
"field_name": (["<dynamic_field_name>"], {"default": "<dynamic_field_name>"}),
|
||||
"field_index": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 100000,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, collection_name, field_name, field_index):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, collection_name: str, field_name: str, field_index: int):
|
||||
return f"{collection_name}|{field_name}|{field_index}"
|
||||
|
||||
def select_field(self, collection_name: str, field_name: str, field_index: int):
|
||||
doc = self.get_sample_document(collection_name)
|
||||
field_names = self.get_flattened_field_names(collection_name)
|
||||
|
||||
if not doc or not field_names:
|
||||
return (
|
||||
str(collection_name),
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
json.dumps([], indent=2),
|
||||
)
|
||||
|
||||
normalized_index = self._clamp_index(field_index, len(field_names))
|
||||
|
||||
selected_field_name = field_name
|
||||
if (
|
||||
not selected_field_name
|
||||
or selected_field_name.startswith("<")
|
||||
or selected_field_name not in field_names
|
||||
):
|
||||
selected_field_name = field_names[normalized_index]
|
||||
|
||||
selected_index = field_names.index(selected_field_name)
|
||||
selected_value = DataProcessor.get_value(doc, selected_field_name)
|
||||
selected_value_str = self._stringify_value(selected_value)
|
||||
|
||||
return (
|
||||
str(collection_name),
|
||||
str(selected_field_name),
|
||||
int(selected_index),
|
||||
selected_value_str,
|
||||
json.dumps(field_names, indent=2),
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZMongoFlattenedFieldDropdownNode": ZMongoFieldSelectorNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZMongoFlattenedFieldDropdownNode": "🧩 ZMongo Flattened Field Dropdown Node",
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZMongoFlattenedFieldSelectorNode:
|
||||
CATEGORY = "ZMongo"
|
||||
FUNCTION = "select_field"
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"collection_name",
|
||||
"selected_field_name",
|
||||
"selected_field_index",
|
||||
"selected_field_value",
|
||||
"flattened_field_names_json",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_zmongo(cls) -> ZMongo:
|
||||
return ZMongo()
|
||||
|
||||
@staticmethod
|
||||
def _clamp_index(value: int, max_len: int) -> int:
|
||||
if max_len <= 0:
|
||||
return 0
|
||||
return max(0, min(int(value), max_len - 1))
|
||||
|
||||
@staticmethod
|
||||
def _stringify_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (dict, list, tuple, bool, int, float)):
|
||||
try:
|
||||
return json.dumps(
|
||||
DataProcessor.to_json_compatible(value),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
except Exception:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_collection_names(cls) -> List[str]:
|
||||
zmongo = None
|
||||
try:
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.run_sync(zmongo.list_collections_async)
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load collection names: %s",
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
data = result.data or {}
|
||||
if not isinstance(data, dict):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = data.get("collections", [])
|
||||
if not isinstance(names, list):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = [str(name) for name in names if str(name).strip()]
|
||||
return names or ["<no_collections_found>"]
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error loading collection names: %s", exc)
|
||||
return ["<mongo_error>"]
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_sample_document(cls, collection_name: str) -> Dict[str, Any]:
|
||||
zmongo = None
|
||||
try:
|
||||
if not collection_name or collection_name.startswith("<"):
|
||||
return {}
|
||||
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.find_one(collection_name, {})
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load sample document for '%s': %s",
|
||||
collection_name,
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return {}
|
||||
|
||||
doc = result.original() if hasattr(result, "original") else result.data
|
||||
return doc if isinstance(doc, dict) else {}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error loading sample document for collection '%s': %s",
|
||||
collection_name,
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_flattened_field_names(cls, collection_name: str) -> List[str]:
|
||||
try:
|
||||
doc = cls.get_sample_document(collection_name)
|
||||
if not doc:
|
||||
return []
|
||||
|
||||
flattened = DataProcessor.flatten_json(doc)
|
||||
if not isinstance(flattened, dict):
|
||||
return []
|
||||
|
||||
return sorted(str(k) for k in flattened.keys())
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error flattening fields for collection '%s': %s",
|
||||
collection_name,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
collections = cls.get_collection_names()
|
||||
default_collection = collections[0] if collections else "<no_collections_found>"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"collection_name": (collections, {"default": default_collection}),
|
||||
"field_name": (["<dynamic_field_name>"], {"default": "<dynamic_field_name>"}),
|
||||
"field_index": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 100000,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, collection_name, field_name, field_index):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, collection_name: str, field_name: str, field_index: int):
|
||||
return f"{collection_name}|{field_name}|{field_index}"
|
||||
|
||||
def select_field(self, collection_name: str, field_name: str, field_index: int):
|
||||
doc = self.get_sample_document(collection_name)
|
||||
field_names = self.get_flattened_field_names(collection_name)
|
||||
|
||||
if not doc or not field_names:
|
||||
return (
|
||||
str(collection_name),
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
json.dumps([], indent=2),
|
||||
)
|
||||
|
||||
normalized_index = self._clamp_index(field_index, len(field_names))
|
||||
|
||||
selected_field_name = field_name
|
||||
if (
|
||||
not selected_field_name
|
||||
or selected_field_name.startswith("<")
|
||||
or selected_field_name not in field_names
|
||||
):
|
||||
selected_field_name = field_names[normalized_index]
|
||||
|
||||
selected_index = field_names.index(selected_field_name)
|
||||
selected_value = DataProcessor.get_value(doc, selected_field_name)
|
||||
selected_value_str = self._stringify_value(selected_value)
|
||||
|
||||
return (
|
||||
str(collection_name),
|
||||
str(selected_field_name),
|
||||
int(selected_index),
|
||||
selected_value_str,
|
||||
json.dumps(field_names, indent=2),
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZMongoFlattenedFieldSelectorNode": ZMongoFlattenedFieldSelectorNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZMongoFlattenedFieldSelectorNode": "🧩 ZMongo Flattened Field Selector Node",
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from bson import ObjectId
|
||||
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZMongoRecordEditorNode:
|
||||
CATEGORY = "ZMongo"
|
||||
FUNCTION = "get_record"
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"record_json",
|
||||
"record_id",
|
||||
"save_status",
|
||||
"flattened_field_names_json",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_zmongo(cls) -> ZMongo:
|
||||
return ZMongo()
|
||||
|
||||
@staticmethod
|
||||
def _clean_record_id(value: Any) -> str:
|
||||
raw = "" if value is None else str(value)
|
||||
cleaned = raw.strip()
|
||||
|
||||
if len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in {"'", '"'}:
|
||||
cleaned = cleaned[1:-1].strip()
|
||||
|
||||
return cleaned
|
||||
|
||||
@classmethod
|
||||
def _build_id_candidates(cls, record_id: Any) -> List[Any]:
|
||||
cleaned = cls._clean_record_id(record_id)
|
||||
if not cleaned:
|
||||
return []
|
||||
|
||||
candidates: List[Any] = [cleaned]
|
||||
if ObjectId.is_valid(cleaned):
|
||||
candidates.insert(0, ObjectId(cleaned))
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _stringify_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.dumps(
|
||||
DataProcessor.to_json_compatible(value),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_collection_names(cls) -> List[str]:
|
||||
zmongo = None
|
||||
try:
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.run_sync(zmongo.list_collections_async)
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load collection names: %s",
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
data = result.data or {}
|
||||
if not isinstance(data, dict):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = data.get("collections", [])
|
||||
if not isinstance(names, list):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = [str(name) for name in names if str(name).strip()]
|
||||
return names or ["<no_collections_found>"]
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error loading collection names: %s", exc)
|
||||
return ["<mongo_error>"]
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def fetch_record(
|
||||
cls,
|
||||
collection_name: str,
|
||||
record_id: str,
|
||||
fallback_record_json: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
zmongo = None
|
||||
try:
|
||||
clean_id = cls._clean_record_id(record_id)
|
||||
|
||||
if clean_id:
|
||||
if not collection_name or collection_name.startswith("<"):
|
||||
return {}
|
||||
|
||||
zmongo = cls._get_zmongo()
|
||||
|
||||
for candidate_id in cls._build_id_candidates(clean_id):
|
||||
result = zmongo.find_one(collection_name, {"_id": candidate_id})
|
||||
|
||||
if result and getattr(result, "success", False):
|
||||
doc = result.original() if hasattr(result, "original") else result.data
|
||||
if isinstance(doc, dict) and doc:
|
||||
return doc
|
||||
|
||||
logger.warning(
|
||||
"Could not fetch record '%s' from '%s' using string/ObjectId lookup.",
|
||||
clean_id,
|
||||
collection_name,
|
||||
)
|
||||
return {}
|
||||
|
||||
if fallback_record_json and str(fallback_record_json).strip():
|
||||
parsed = json.loads(fallback_record_json)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
return {}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Error fetching record for collection='%s', record_id='%s': %s",
|
||||
collection_name,
|
||||
record_id,
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_flattened_record_pairs(
|
||||
cls,
|
||||
collection_name: str,
|
||||
record_id: str,
|
||||
fallback_record_json: str = "",
|
||||
) -> List[Tuple[str, str]]:
|
||||
record = cls.fetch_record(collection_name, record_id, fallback_record_json)
|
||||
if not record:
|
||||
return []
|
||||
|
||||
flat = DataProcessor.flatten_json(record)
|
||||
if not isinstance(flat, dict):
|
||||
return []
|
||||
|
||||
return [
|
||||
(str(path), cls._stringify_value(value))
|
||||
for path, value in sorted(flat.items(), key=lambda item: str(item[0]))
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
collections = cls.get_collection_names()
|
||||
default_collection = collections[0] if collections else "<no_collections_found>"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"collection_name": (collections, {"default": default_collection}),
|
||||
"record_id": ("STRING", {"default": "", "multiline": False}),
|
||||
},
|
||||
"optional": {
|
||||
"selected_record_json": ("STRING", {"default": "", "forceInput": True}),
|
||||
"save_status_in": ("STRING", {"default": "", "forceInput": True}),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(
|
||||
cls,
|
||||
collection_name: str,
|
||||
record_id: str,
|
||||
selected_record_json: str = "",
|
||||
save_status_in: str = "",
|
||||
):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
collection_name: str,
|
||||
record_id: str,
|
||||
selected_record_json: str = "",
|
||||
save_status_in: str = "",
|
||||
):
|
||||
return f"{collection_name}|{record_id}|{selected_record_json}|{save_status_in}"
|
||||
|
||||
def get_record(
|
||||
self,
|
||||
collection_name: str,
|
||||
record_id: str,
|
||||
selected_record_json: str = "",
|
||||
save_status_in: str = "",
|
||||
):
|
||||
record = self.fetch_record(
|
||||
collection_name=collection_name,
|
||||
record_id=record_id,
|
||||
fallback_record_json=selected_record_json,
|
||||
)
|
||||
|
||||
if not record:
|
||||
return (
|
||||
"{}",
|
||||
self._clean_record_id(record_id),
|
||||
str(save_status_in or ""),
|
||||
json.dumps([], indent=2),
|
||||
)
|
||||
|
||||
resolved_record_id = str(record.get("_id", self._clean_record_id(record_id)))
|
||||
flattened_paths = [
|
||||
path
|
||||
for path, _ in self.get_flattened_record_pairs(
|
||||
collection_name=collection_name,
|
||||
record_id=resolved_record_id,
|
||||
fallback_record_json=selected_record_json,
|
||||
)
|
||||
]
|
||||
|
||||
return (
|
||||
json.dumps(
|
||||
DataProcessor.to_json_compatible(record),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
),
|
||||
resolved_record_id,
|
||||
str(save_status_in or ""),
|
||||
json.dumps(flattened_paths, ensure_ascii=False, indent=2),
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZMongoRecordEditorNode": ZMongoRecordEditorNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZMongoRecordEditorNode": "📝 ZMongo Record Editor",
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
|
||||
class ZMongoRecordSplitter:
|
||||
"""Converts JSON arrays into individual processable strings for batching."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"records_json": ("STRING", {"multiline": True})}}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT")
|
||||
RETURN_NAMES = ("record_json_list", "count")
|
||||
OUTPUT_IS_LIST = (True, False)
|
||||
FUNCTION = "split"
|
||||
CATEGORY = "ZMongo/Database"
|
||||
|
||||
def split(self, records_json):
|
||||
try:
|
||||
# Handles concatenated objects and arrays
|
||||
data = json.loads(records_json)
|
||||
records = data if isinstance(data, list) else [data]
|
||||
output = [DataProcessor.to_json(r) for r in records]
|
||||
return (output, len(output))
|
||||
except Exception as e:
|
||||
return ([json.dumps({"error": str(e)})], 0)
|
||||
|
||||
|
||||
class ZMongoFieldSelector:
|
||||
"""Dynamic dot-notation field extractor with metadata for UI dropdowns."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"record_json": ("STRING", {"forceInput": True}),
|
||||
"field_path": ("STRING", {"default": "metadata.case_name"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("field_value", "available_paths")
|
||||
FUNCTION = "select_field"
|
||||
CATEGORY = "ZMongo/Database"
|
||||
|
||||
def select_field(self, record_json, field_path):
|
||||
record = json.loads(record_json)
|
||||
# Extract nested values using DataProcessor dot-notation
|
||||
val = DataProcessor.get_value(record, field_path)
|
||||
|
||||
# Output paths to allow JS extension to build dropdowns
|
||||
flattened = DataProcessor.flatten_dict(record)
|
||||
available_paths = "\n".join(sorted(flattened.keys()))
|
||||
|
||||
return (str(val), available_paths)
|
||||
@@ -0,0 +1,367 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from .zmongo_toolbag.zmongo import ZMongo
|
||||
from .zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZMongoTabularRecordViewNode:
|
||||
CATEGORY = "ZMongo"
|
||||
FUNCTION = "select_record"
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "INT", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"selected_record_json",
|
||||
"selected_record_id",
|
||||
"selected_field_name",
|
||||
"selected_record_index",
|
||||
"selected_field_value",
|
||||
"flattened_headings_json",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@staticmethod
|
||||
def _value_to_searchable_string(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (bool, int, float)):
|
||||
return str(value)
|
||||
try:
|
||||
return json.dumps(
|
||||
DataProcessor.to_json_compatible(value),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _wildcard_to_regex(pattern: str) -> str:
|
||||
"""
|
||||
Convert a simple wildcard pattern to regex.
|
||||
Supported:
|
||||
* => any characters
|
||||
? => any single character
|
||||
"""
|
||||
escaped = re.escape(pattern)
|
||||
escaped = escaped.replace(r"\*", ".*")
|
||||
escaped = escaped.replace(r"\?", ".")
|
||||
return f"^{escaped}$"
|
||||
|
||||
@classmethod
|
||||
def _matches_search_text(cls, value: Any, search_text: str) -> bool:
|
||||
"""
|
||||
Matching rules:
|
||||
- empty search_text => match everything
|
||||
- if search_text contains * or ?, use wildcard matching
|
||||
- otherwise do case-insensitive substring matching
|
||||
"""
|
||||
haystack = cls._value_to_searchable_string(value)
|
||||
needle = str(search_text or "")
|
||||
|
||||
if not needle:
|
||||
return True
|
||||
|
||||
haystack_lower = haystack.lower()
|
||||
needle_lower = needle.lower()
|
||||
|
||||
if "*" in needle or "?" in needle:
|
||||
regex = cls._wildcard_to_regex(needle_lower)
|
||||
return re.search(regex, haystack_lower) is not None
|
||||
|
||||
return needle_lower in haystack_lower
|
||||
|
||||
@classmethod
|
||||
def get_filtered_table_payload(
|
||||
cls,
|
||||
collection_name: str,
|
||||
search_text: str = "",
|
||||
flattened_field_name: str = "",
|
||||
) -> Tuple[List[str], List[Dict[str, Any]], List[str]]:
|
||||
records = cls.get_all_records(collection_name)
|
||||
search_text = str(search_text or "").strip()
|
||||
flattened_field_name = str(flattened_field_name or "").strip()
|
||||
|
||||
headings: List[str] = []
|
||||
seen = set()
|
||||
flat_records: List[Dict[str, Any]] = []
|
||||
record_ids: List[str] = []
|
||||
|
||||
for record in records:
|
||||
flat = DataProcessor.flatten_json(record)
|
||||
normalized_flat = {
|
||||
str(k): DataProcessor.to_json_compatible(v)
|
||||
for k, v in flat.items()
|
||||
}
|
||||
|
||||
if search_text:
|
||||
if flattened_field_name:
|
||||
target_value = normalized_flat.get(flattened_field_name)
|
||||
matched = cls._matches_search_text(target_value, search_text)
|
||||
else:
|
||||
matched = any(
|
||||
cls._matches_search_text(v, search_text)
|
||||
for v in normalized_flat.values()
|
||||
)
|
||||
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
flat_records.append(normalized_flat)
|
||||
|
||||
record_id = str(record.get("_id", ""))
|
||||
record_ids.append(record_id)
|
||||
|
||||
for key in normalized_flat.keys():
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
headings.append(key)
|
||||
|
||||
return headings, flat_records, record_ids
|
||||
|
||||
@classmethod
|
||||
def _get_zmongo(cls) -> ZMongo:
|
||||
return ZMongo()
|
||||
|
||||
@staticmethod
|
||||
def _clamp_index(value: int, max_len: int) -> int:
|
||||
if max_len <= 0:
|
||||
return 0
|
||||
return max(0, min(int(value), max_len - 1))
|
||||
|
||||
@staticmethod
|
||||
def _stringify_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.dumps(
|
||||
DataProcessor.to_json_compatible(value),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_collection_names(cls) -> List[str]:
|
||||
zmongo = None
|
||||
try:
|
||||
zmongo = cls._get_zmongo()
|
||||
result = zmongo.run_sync(zmongo.list_collections_async)
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load collection names: %s",
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
data = result.data or {}
|
||||
if not isinstance(data, dict):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = data.get("collections", [])
|
||||
if not isinstance(names, list):
|
||||
return ["<no_collections_found>"]
|
||||
|
||||
names = [str(name) for name in names if str(name).strip()]
|
||||
return names or ["<no_collections_found>"]
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error loading collection names: %s", exc)
|
||||
return ["<mongo_error>"]
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_all_records(cls, collection_name: str) -> List[Dict[str, Any]]:
|
||||
zmongo = None
|
||||
try:
|
||||
if not collection_name or collection_name.startswith("<"):
|
||||
return []
|
||||
|
||||
zmongo = cls._get_zmongo()
|
||||
|
||||
result = zmongo.find_many(
|
||||
collection_name,
|
||||
query={},
|
||||
sort=[("_id", 1)],
|
||||
limit=None, # all records
|
||||
)
|
||||
|
||||
if not result or not getattr(result, "success", False):
|
||||
logger.warning(
|
||||
"Could not load records for '%s': %s",
|
||||
collection_name,
|
||||
getattr(result, "error", None),
|
||||
)
|
||||
return []
|
||||
|
||||
data = result.original() if hasattr(result, "original") else result.data
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
|
||||
return [record for record in data if isinstance(record, dict)]
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error loading records for '%s': %s", collection_name, exc)
|
||||
return []
|
||||
finally:
|
||||
if zmongo:
|
||||
try:
|
||||
zmongo.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_table_payload(cls, collection_name: str) -> Tuple[List[str], List[Dict[str, Any]], List[str]]:
|
||||
records = cls.get_all_records(collection_name)
|
||||
|
||||
headings: List[str] = []
|
||||
seen = set()
|
||||
flat_records: List[Dict[str, Any]] = []
|
||||
record_ids: List[str] = []
|
||||
|
||||
for record in records:
|
||||
flat = DataProcessor.flatten_json(record)
|
||||
normalized_flat = {
|
||||
str(k): DataProcessor.to_json_compatible(v)
|
||||
for k, v in flat.items()
|
||||
}
|
||||
flat_records.append(normalized_flat)
|
||||
|
||||
record_id = str(record.get("_id", ""))
|
||||
record_ids.append(record_id)
|
||||
|
||||
for key in normalized_flat.keys():
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
headings.append(key)
|
||||
|
||||
return headings, flat_records, record_ids
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
collections = cls.get_collection_names()
|
||||
default_collection = collections[0] if collections else "<no_collections_found>"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"collection_name": (collections, {"default": default_collection}),
|
||||
"flattened_field_name": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
},
|
||||
),
|
||||
"record_id": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
},
|
||||
),
|
||||
"selected_record_index": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 1000000,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, collection_name, flattened_field_name, record_id, selected_record_index):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, collection_name: str, flattened_field_name: str, record_id: str, selected_record_index: int):
|
||||
return f"{collection_name}|{flattened_field_name}|{record_id}|{selected_record_index}"
|
||||
|
||||
def select_record(
|
||||
self,
|
||||
collection_name: str,
|
||||
flattened_field_name: str,
|
||||
record_id: str,
|
||||
selected_record_index: int,
|
||||
):
|
||||
records = self.get_all_records(collection_name)
|
||||
headings, _, record_ids = self.get_table_payload(collection_name)
|
||||
|
||||
if not records:
|
||||
return (
|
||||
"{}",
|
||||
"",
|
||||
str(flattened_field_name or ""),
|
||||
0,
|
||||
"",
|
||||
json.dumps([], indent=2),
|
||||
)
|
||||
|
||||
selected_idx = None
|
||||
|
||||
if record_id:
|
||||
record_id = str(record_id).strip()
|
||||
for idx, rid in enumerate(record_ids):
|
||||
if rid == record_id:
|
||||
selected_idx = idx
|
||||
break
|
||||
|
||||
if selected_idx is None:
|
||||
selected_idx = self._clamp_index(selected_record_index, len(records))
|
||||
|
||||
selected_record = records[selected_idx]
|
||||
selected_record_id = str(selected_record.get("_id", ""))
|
||||
|
||||
# default record_id behavior: if blank, use record 0 / selected record
|
||||
if not record_id:
|
||||
record_id = selected_record_id
|
||||
|
||||
selected_field_name = str(flattened_field_name or "").strip()
|
||||
selected_field_value = ""
|
||||
if selected_field_name:
|
||||
value = DataProcessor.get_value(selected_record, selected_field_name)
|
||||
selected_field_value = self._stringify_value(value)
|
||||
|
||||
selected_record_json = json.dumps(
|
||||
DataProcessor.to_json_compatible(selected_record),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
|
||||
return (
|
||||
selected_record_json,
|
||||
selected_record_id,
|
||||
selected_field_name,
|
||||
int(selected_idx),
|
||||
selected_field_value,
|
||||
json.dumps(headings, ensure_ascii=False, indent=2),
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZMongoTabularRecordViewNode": ZMongoTabularRecordViewNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZMongoTabularRecordViewNode": "📊 ZMongo Tabular Record View",
|
||||
}
|
||||
+2
-1
@@ -17,4 +17,5 @@ FlagEmbedding
|
||||
transformers
|
||||
pymilvus[model]
|
||||
FlagEmbedding
|
||||
pytest
|
||||
pytest
|
||||
quart
|
||||
@@ -0,0 +1,28 @@
|
||||
import logging
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PromptServer:
|
||||
"""A mock of the ComfyUI PromptServer for standalone testing."""
|
||||
instance = None
|
||||
|
||||
def __init__(self):
|
||||
self.app = web.Application()
|
||||
self.routes = web.RouteTableDef()
|
||||
# Initialize the singleton instance
|
||||
PromptServer.instance = self
|
||||
|
||||
def add_routes(self):
|
||||
"""Finalize and add the collected routes to the app."""
|
||||
self.app.add_routes(self.routes)
|
||||
|
||||
async def start(self, host="127.0.0.1", port=8188):
|
||||
"""Starts a real local web server."""
|
||||
self.add_routes()
|
||||
runner = web.AppRunner(self.app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, host, port)
|
||||
print(f"🚀 ZMongo Demo Server started at http://{host}:{port}")
|
||||
print(f"🔗 Test Link: http://{host}:{port}/zmongo/flattened_fields?collection_name=test")
|
||||
await site.start()
|
||||
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
|
||||
# 1. PATH CONFIGURATION 🛠️
|
||||
# Ensures we can find 'nodes' and 'zmongo_toolbag'
|
||||
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
|
||||
if ROOT_PATH not in sys.path:
|
||||
sys.path.append(ROOT_PATH)
|
||||
|
||||
# 2. WEB ASSET REGISTRATION 🌐
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("ZMongo-Demo")
|
||||
|
||||
# 3. IMPORTS
|
||||
# We import the routes and the server class we just made
|
||||
from server import PromptServer
|
||||
from nodes.zmongo_field_selector_api import register_zmongo_field_selector_routes
|
||||
from nodes.zmongo_record_editor_api import register_zmongo_record_editor_routes
|
||||
from nodes.zmongo_tabular_record_view_api import register_zmongo_tabular_record_view_routes
|
||||
|
||||
|
||||
# 4. SERVER API REGISTRATION 🚦
|
||||
def setup_server():
|
||||
server = PromptServer()
|
||||
|
||||
# Register your custom routes with the server instance
|
||||
register_zmongo_field_selector_routes(server)
|
||||
register_zmongo_record_editor_routes(server)
|
||||
register_zmongo_tabular_record_view_routes(server)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
# 5. RUNNABLE DEMO 🚀
|
||||
if __name__ == "__main__":
|
||||
# Create the server environment
|
||||
demo_server = setup_server()
|
||||
|
||||
# Run the async loop
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(demo_server.start())
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping ZMongo Demo Server...")
|
||||
@@ -1,13 +1,29 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 1. LIVE ENVIRONMENT MIMICRY 🎭
|
||||
# This finds the 'ComfyUI-ZMongo' folder and adds it to the search path.
|
||||
# It mimics exactly what your root __init__.py does when ComfyUI starts.
|
||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
|
||||
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.append(PROJECT_ROOT)
|
||||
|
||||
# 2. ABSOLUTE PACKAGE IMPORT 📦
|
||||
# Now we can import DataProcessor as if it were a global package.
|
||||
from zmongo_toolbag.data_processor import DataProcessor
|
||||
|
||||
# --- Rest of your original imports ---
|
||||
import datetime
|
||||
import math
|
||||
import uuid
|
||||
from collections import deque
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from bson import ObjectId
|
||||
|
||||
from zmongo_toolbag.data_processor import DataProcessor
|
||||
# ... rest of your test classes and functions ...
|
||||
|
||||
|
||||
class DummyModelDump:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from .local_vector_search import LocalVectorSearch
|
||||
from .zembedder import ZEmbedder
|
||||
from .data_processor import DataProcessor
|
||||
from .safe_result import SafeResult
|
||||
from .zmongo import ZMongo
|
||||
|
||||
__all__ = [
|
||||
DataProcessor,
|
||||
SafeResult,
|
||||
ZMongo,
|
||||
ZEmbedder,
|
||||
LocalVectorSearch,
|
||||
]
|
||||
|
||||
# __all__ = [
|
||||
# "DataProcessor",
|
||||
# "SafeResult",
|
||||
# "ZMongo",
|
||||
# "ZEmbedder",
|
||||
# "LocalVectorSearch",
|
||||
# ]
|
||||
@@ -0,0 +1,434 @@
|
||||
import base64
|
||||
import datetime
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import uuid
|
||||
from collections import deque
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
try:
|
||||
from bson import ObjectId
|
||||
except ImportError: # pragma: no cover - bson should normally be available
|
||||
from bson.objectid import ObjectId # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DataProcessor:
|
||||
"""Centralized data processing, normalization, flattening, and text helpers."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Entity & index helpers (legacy compatibility)
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def get_entity_name(data_row: Sequence[Any]) -> Tuple[str, str]:
|
||||
"""Build a fallback entity name from the populated values in a row."""
|
||||
last_index = DataProcessor.get_index_last_non_nan(data_row)
|
||||
entity_name = DataProcessor.get_string(data_row, 0, last_index)
|
||||
return "Entity", entity_name
|
||||
|
||||
@staticmethod
|
||||
def get_index_last_non_excluded(
|
||||
name_row: Sequence[Any],
|
||||
excluded_parts: Optional[set] = None,
|
||||
) -> Optional[int]:
|
||||
"""Return the last non-empty index whose value is not excluded."""
|
||||
excluded_parts = excluded_parts or set()
|
||||
last_value_index = DataProcessor.get_index_last_non_nan(name_row)
|
||||
|
||||
for i in range(last_value_index, -1, -1):
|
||||
try:
|
||||
value = name_row[i]
|
||||
if value not in excluded_parts:
|
||||
return i
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Error in get_index_last_non_excluded: %s", exc)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_index_last_non_nan(data: Sequence[Any]) -> int:
|
||||
"""Return the last index whose value is not None and not NaN-like."""
|
||||
for i in range(len(data) - 1, -1, -1):
|
||||
value = data[i]
|
||||
if value is not None and str(value).lower() != "nan":
|
||||
return i
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def get_string(data: Sequence[Any], start: int, end: int) -> str:
|
||||
"""Join a slice of values into a single space-delimited string."""
|
||||
if not data:
|
||||
return ""
|
||||
end = min(end, len(data) - 1)
|
||||
start = max(start, 0)
|
||||
if end < start:
|
||||
return ""
|
||||
parts = data[start : end + 1]
|
||||
return " ".join(str(part) for part in parts if part is not None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BSON / datetime normalization
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def normalize_objectid(obj: Any) -> Any:
|
||||
"""Legacy alias retained for backward compatibility."""
|
||||
return DataProcessor.to_json_compatible(obj)
|
||||
|
||||
@staticmethod
|
||||
def to_json_compatible(
|
||||
data: Any,
|
||||
_seen: Optional[set] = None,
|
||||
*,
|
||||
max_depth: int = 50,
|
||||
_depth: int = 0,
|
||||
) -> Any:
|
||||
"""Recursively convert arbitrary Python objects into JSON-compatible data."""
|
||||
if _seen is None:
|
||||
_seen = set()
|
||||
|
||||
if _depth > max_depth:
|
||||
return {"__truncated__": f"max_depth_exceeded:{max_depth}"}
|
||||
|
||||
if data is None or isinstance(data, (bool, int, str)):
|
||||
return data
|
||||
|
||||
if isinstance(data, float):
|
||||
if math.isnan(data):
|
||||
return "NaN"
|
||||
if math.isinf(data):
|
||||
return "Infinity" if data > 0 else "-Infinity"
|
||||
return data
|
||||
|
||||
if isinstance(data, ObjectId):
|
||||
return str(data)
|
||||
|
||||
if isinstance(data, uuid.UUID):
|
||||
return str(data)
|
||||
|
||||
if isinstance(data, Decimal):
|
||||
try:
|
||||
if data == data.to_integral_value():
|
||||
return int(data)
|
||||
return float(data)
|
||||
except Exception:
|
||||
return str(data)
|
||||
|
||||
if isinstance(data, (datetime.datetime, datetime.date, datetime.time)):
|
||||
try:
|
||||
return data.isoformat()
|
||||
except Exception:
|
||||
return str(data)
|
||||
|
||||
if isinstance(data, re.Pattern):
|
||||
return data.pattern
|
||||
|
||||
if isinstance(data, (bytes, bytearray, memoryview)):
|
||||
raw = bytes(data)
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except Exception:
|
||||
return {
|
||||
"__type__": "bytes",
|
||||
"encoding": "base64",
|
||||
"data": base64.b64encode(raw).decode("ascii"),
|
||||
}
|
||||
|
||||
if isinstance(data, BaseException):
|
||||
return {
|
||||
"__type__": data.__class__.__name__,
|
||||
"message": str(data),
|
||||
"args": DataProcessor.to_json_compatible(
|
||||
list(getattr(data, "args", [])),
|
||||
_seen=_seen.copy(),
|
||||
max_depth=max_depth,
|
||||
_depth=_depth + 1,
|
||||
),
|
||||
}
|
||||
|
||||
needs_cycle_tracking = isinstance(
|
||||
data,
|
||||
(dict, list, tuple, set, frozenset, deque),
|
||||
) or hasattr(data, "__dict__") or hasattr(data, "model_dump") or hasattr(data, "dict")
|
||||
|
||||
if needs_cycle_tracking:
|
||||
obj_id = id(data)
|
||||
if obj_id in _seen:
|
||||
return {"__circular_reference__": type(data).__name__}
|
||||
_seen.add(obj_id)
|
||||
|
||||
if isinstance(data, dict):
|
||||
converted: Dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(key, (str, int, float, bool)) or key is None:
|
||||
safe_key = str(key)
|
||||
else:
|
||||
safe_key = str(
|
||||
DataProcessor.to_json_compatible(
|
||||
key,
|
||||
_seen=_seen.copy(),
|
||||
max_depth=max_depth,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
)
|
||||
|
||||
converted[safe_key] = DataProcessor.to_json_compatible(
|
||||
value,
|
||||
_seen=_seen.copy(),
|
||||
max_depth=max_depth,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
return converted
|
||||
|
||||
if isinstance(data, (list, tuple, set, frozenset, deque)):
|
||||
iterable = list(data)
|
||||
if isinstance(data, (set, frozenset)):
|
||||
try:
|
||||
iterable = sorted(iterable, key=lambda x: repr(x))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return [
|
||||
DataProcessor.to_json_compatible(
|
||||
item,
|
||||
_seen=_seen.copy(),
|
||||
max_depth=max_depth,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
for item in iterable
|
||||
]
|
||||
|
||||
try:
|
||||
import numpy as np # type: ignore
|
||||
|
||||
if isinstance(data, np.generic):
|
||||
return DataProcessor.to_json_compatible(
|
||||
data.item(),
|
||||
_seen=_seen.copy(),
|
||||
max_depth=max_depth,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
|
||||
if isinstance(data, np.ndarray):
|
||||
return DataProcessor.to_json_compatible(
|
||||
data.tolist(),
|
||||
_seen=_seen.copy(),
|
||||
max_depth=max_depth,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import pandas as pd # type: ignore
|
||||
|
||||
if isinstance(data, pd.DataFrame):
|
||||
return DataProcessor.to_json_compatible(
|
||||
data.to_dict(orient="records"),
|
||||
_seen=_seen.copy(),
|
||||
max_depth=max_depth,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
|
||||
if isinstance(data, pd.Series):
|
||||
return DataProcessor.to_json_compatible(
|
||||
data.to_dict(),
|
||||
_seen=_seen.copy(),
|
||||
max_depth=max_depth,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(data, "model_dump") and callable(getattr(data, "model_dump")):
|
||||
try:
|
||||
dumped = data.model_dump()
|
||||
return DataProcessor.to_json_compatible(
|
||||
dumped,
|
||||
_seen=_seen.copy(),
|
||||
max_depth=max_depth,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(data, "dict") and callable(getattr(data, "dict")):
|
||||
try:
|
||||
dumped = data.dict()
|
||||
return DataProcessor.to_json_compatible(
|
||||
dumped,
|
||||
_seen=_seen.copy(),
|
||||
max_depth=max_depth,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(data, "__dict__"):
|
||||
try:
|
||||
attrs = {
|
||||
key: value
|
||||
for key, value in vars(data).items()
|
||||
if not key.startswith("_")
|
||||
}
|
||||
return DataProcessor.to_json_compatible(
|
||||
attrs,
|
||||
_seen=_seen.copy(),
|
||||
max_depth=max_depth,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return str(data)
|
||||
except Exception:
|
||||
return f"<unserializable {type(data).__name__}>"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JSON serialization
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def to_json(data: Any, indent: Optional[int] = None) -> str:
|
||||
"""Serialize arbitrary data to JSON, coercing unsupported values."""
|
||||
try:
|
||||
normalized = DataProcessor.to_json_compatible(data)
|
||||
return json.dumps(normalized, indent=indent, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
logger.error("Error serializing to JSON: %s", exc)
|
||||
return json.dumps({"error": str(exc)})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Flattening helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def flatten_json(data: Any, parent_key: str = "", sep: str = ".") -> Dict[str, Any]:
|
||||
"""Flatten nested dict/list structures using dot-path keys."""
|
||||
flat: Dict[str, Any] = {}
|
||||
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
new_key = f"{parent_key}{sep}{key}" if parent_key else str(key)
|
||||
flat.update(DataProcessor.flatten_json(value, new_key, sep))
|
||||
return flat
|
||||
|
||||
if isinstance(data, list):
|
||||
for index, value in enumerate(data):
|
||||
new_key = f"{parent_key}{sep}{index}" if parent_key else str(index)
|
||||
flat.update(DataProcessor.flatten_json(value, new_key, sep))
|
||||
return flat
|
||||
|
||||
if parent_key:
|
||||
flat[parent_key] = data
|
||||
return flat
|
||||
|
||||
@staticmethod
|
||||
def flatten_dict(data: Dict[str, Any], sep: str = ".") -> Dict[str, Any]:
|
||||
"""Legacy wrapper expected by older callers."""
|
||||
return DataProcessor.flatten_json(data, sep=sep)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path-based value access
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def get_value(data: Union[Dict[str, Any], List[Any]], key: str) -> Any:
|
||||
"""Fetch a nested value using a dot-path key."""
|
||||
if not key:
|
||||
return data
|
||||
|
||||
value: Any = data
|
||||
for part in key.split("."):
|
||||
if isinstance(value, dict):
|
||||
value = value.get(part)
|
||||
elif isinstance(value, list) and part.isdigit():
|
||||
index = int(part)
|
||||
value = value[index] if 0 <= index < len(value) else None
|
||||
else:
|
||||
return None
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def set_value(data: Union[Dict[str, Any], List[Any]], key: str, val: Any) -> bool:
|
||||
"""Set a nested value using a dot-path key."""
|
||||
if not isinstance(data, (dict, list)) or not key:
|
||||
return False
|
||||
|
||||
parts = key.split(".")
|
||||
target: Any = data
|
||||
|
||||
for part in parts[:-1]:
|
||||
if isinstance(target, dict):
|
||||
next_target = target.get(part)
|
||||
if next_target is None:
|
||||
next_target = {}
|
||||
target[part] = next_target
|
||||
target = next_target
|
||||
elif isinstance(target, list) and part.isdigit():
|
||||
index = int(part)
|
||||
if not (0 <= index < len(target)):
|
||||
return False
|
||||
target = target[index]
|
||||
else:
|
||||
return False
|
||||
|
||||
last = parts[-1]
|
||||
if isinstance(target, dict):
|
||||
target[last] = val
|
||||
return True
|
||||
if isinstance(target, list) and last.isdigit():
|
||||
index = int(last)
|
||||
if 0 <= index < len(target):
|
||||
target[index] = val
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text / HTML helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def clean_output_text(text: str) -> str:
|
||||
"""Strip Markdown code fences and unescape literal newlines."""
|
||||
if not isinstance(text, str):
|
||||
raise ValueError("Input must be a string")
|
||||
|
||||
cleaned = text.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = re.sub(r"^```[a-zA-Z0-9_-]*\n?", "", cleaned)
|
||||
if cleaned.endswith("```"):
|
||||
cleaned = cleaned[:-3]
|
||||
|
||||
return cleaned.replace("\\n", "\n").strip()
|
||||
|
||||
@staticmethod
|
||||
def convert_text_to_html(data: Union[str, Dict[str, Any]]) -> str:
|
||||
"""Convert escaped HTML text to rendered HTML text."""
|
||||
if isinstance(data, str):
|
||||
text = data
|
||||
elif isinstance(data, dict):
|
||||
if "output_text" not in data:
|
||||
raise ValueError("Dictionary must contain 'output_text' key for HTML conversion.")
|
||||
text = data.get("output_text", "")
|
||||
else:
|
||||
raise ValueError(f"Invalid input type: {type(data)}. Expected str or dict.")
|
||||
|
||||
if not isinstance(text, str):
|
||||
raise ValueError("Invalid text for HTML conversion")
|
||||
|
||||
decoded = html.unescape(text)
|
||||
decoded = re.sub(r">\s+<", "> <", decoded)
|
||||
return decoded
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Object conversion helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def convert_object_to_json(obj: Any, _seen: Optional[set] = None) -> Any:
|
||||
"""Recursively coerce arbitrary Python objects into JSON-safe values."""
|
||||
return DataProcessor.to_json_compatible(obj, _seen=_seen)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
LocalVectorSearch – SafeResult-compatible vector similarity search engine
|
||||
for ZMongo repositories (sync or async).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from concurrent.futures import Future
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .safe_result import SafeResult
|
||||
|
||||
# Standard imports without leading dots for this environment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _run_async_in_new_thread(coro) -> Any:
|
||||
fut: Future[Any] = Future()
|
||||
def _runner():
|
||||
try:
|
||||
fut.set_result(asyncio.run(coro))
|
||||
except Exception as e:
|
||||
fut.set_exception(e)
|
||||
t = threading.Thread(target=_runner, daemon=True)
|
||||
t.start()
|
||||
return fut.result()
|
||||
|
||||
class LocalVectorSearch:
|
||||
def __init__(
|
||||
self,
|
||||
repository: Any,
|
||||
collection: str,
|
||||
embedding_field: str = "embedding",
|
||||
chunked_embeddings: bool = False,
|
||||
score_mode: str = "cosine_0_1",
|
||||
field_key: Optional[str] = None,
|
||||
vector_key: str = "vectors",
|
||||
max_docs: int = 1_000_000,
|
||||
):
|
||||
self.repo = repository
|
||||
self.collection = collection
|
||||
self.embedding_field = embedding_field
|
||||
self.chunked_embeddings = chunked_embeddings
|
||||
self.score_mode = score_mode
|
||||
self.field_key = field_key
|
||||
self.vector_key = vector_key
|
||||
self.max_docs = max_docs
|
||||
self._index_matrix: Optional[np.ndarray] = None
|
||||
self._meta_docs: List[Dict[str, Any]] = []
|
||||
self._load_lock = asyncio.Lock()
|
||||
|
||||
def clear_index(self) -> None:
|
||||
"""Resets the in-memory index matrix and metadata for graceful shutdown."""
|
||||
self._index_matrix = None
|
||||
self._meta_docs = []
|
||||
logger.info("Local vector index cleared.")
|
||||
|
||||
async def _await_repo_result(self, maybe_result: Any) -> SafeResult:
|
||||
if asyncio.iscoroutine(maybe_result):
|
||||
maybe_result = await maybe_result
|
||||
return maybe_result if isinstance(maybe_result, SafeResult) else SafeResult.ok(maybe_result)
|
||||
|
||||
async def _find_all_docs(self) -> SafeResult:
|
||||
try:
|
||||
# Standardizing on ZMongo find_many
|
||||
repo_call = self.repo.find_many(self.collection, query={}, limit=self.max_docs)
|
||||
return await self._await_repo_result(repo_call)
|
||||
except Exception as e:
|
||||
return SafeResult.fail(f"find_many failed: {e}")
|
||||
|
||||
def _extract_vectors_from_doc(self, doc: Dict[str, Any]) -> List[List[float]]:
|
||||
emb_container = doc.get(self.embedding_field)
|
||||
if not emb_container: return []
|
||||
emb_entry = emb_container
|
||||
if self.field_key is not None and isinstance(emb_container, dict):
|
||||
emb_entry = emb_container.get(self.field_key)
|
||||
if not emb_entry: return []
|
||||
vec_data = emb_entry
|
||||
if isinstance(emb_entry, dict) and self.vector_key in emb_entry:
|
||||
vec_data = emb_entry.get(self.vector_key)
|
||||
if not vec_data: return []
|
||||
if isinstance(vec_data, list) and vec_data and isinstance(vec_data[0], list):
|
||||
if self.chunked_embeddings:
|
||||
return [v for v in vec_data if isinstance(v, list) and v]
|
||||
return [vec_data[0]] if vec_data[0] else []
|
||||
if isinstance(vec_data, list):
|
||||
return [vec_data] if vec_data else []
|
||||
return []
|
||||
|
||||
async def _ensure_index(self) -> SafeResult:
|
||||
if self._index_matrix is not None:
|
||||
return SafeResult.ok({"count": int(self._index_matrix.shape[0])})
|
||||
async with self._load_lock:
|
||||
if self._index_matrix is not None:
|
||||
return SafeResult.ok({"count": int(self._index_matrix.shape[0])})
|
||||
return await self.rebuild_index()
|
||||
|
||||
def rebuild_index_sync(self) -> SafeResult:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
if loop.is_running(): return _run_async_in_new_thread(self.rebuild_index())
|
||||
except RuntimeError: pass
|
||||
return asyncio.run(self.rebuild_index())
|
||||
|
||||
async def search(self, query_vector: Any, top_k: int = 5) -> SafeResult:
|
||||
ensure_res = await self._ensure_index()
|
||||
if not ensure_res.success: return ensure_res
|
||||
|
||||
if self._index_matrix is None or self._index_matrix.size == 0:
|
||||
return SafeResult.ok([])
|
||||
|
||||
try:
|
||||
# FORCE conversion to a 1D float array regardless of input type
|
||||
q = np.array(query_vector, dtype=float).flatten()
|
||||
|
||||
dim = int(self._index_matrix.shape[1])
|
||||
if q.shape[0] != dim:
|
||||
return SafeResult.fail(f"Dimension mismatch: Query {q.shape[0]} != Index {dim}")
|
||||
M = self._index_matrix
|
||||
qn, Mn = np.linalg.norm(q), np.linalg.norm(M, axis=1)
|
||||
denom = Mn * qn
|
||||
valid = denom > 0
|
||||
cos = np.zeros(M.shape[0], dtype=float)
|
||||
cos[valid] = (M[valid] @ q) / denom[valid]
|
||||
scores = self._to_output_scores(cos)
|
||||
k = min(max(1, int(top_k)), int(M.shape[0]))
|
||||
idx = np.argpartition(scores, -k)[-k:]
|
||||
idx = idx[np.argsort(scores[idx])[::-1]]
|
||||
out = [{"document": self._meta_docs[i], "retrieval_score": float(scores[i])} for i in idx]
|
||||
return SafeResult.ok(out)
|
||||
except Exception as e: return SafeResult.fail(str(e))
|
||||
|
||||
def _to_output_scores(self, cos: np.ndarray) -> np.ndarray:
|
||||
mode = (self.score_mode or "cosine_0_1").lower().strip()
|
||||
if mode == "cosine_0_1": return 0.5 * (cos + 1.0)
|
||||
return cos.astype(float)
|
||||
|
||||
async def rebuild_index(self) -> SafeResult:
|
||||
"""
|
||||
Fetches all documents from the repository, extracts vectors,
|
||||
and builds the in-memory NumPy search matrix.
|
||||
"""
|
||||
try:
|
||||
# 1. Fetch documents from the ZMongo repository
|
||||
docs_res = await self._find_all_docs()
|
||||
if not docs_res.success:
|
||||
return docs_res
|
||||
|
||||
all_docs = docs_res.data if isinstance(docs_res.data, list) else []
|
||||
|
||||
temp_vectors = []
|
||||
temp_meta = []
|
||||
expected_dim = None
|
||||
|
||||
# 2. Extract vectors and maintain metadata mapping
|
||||
for doc in all_docs:
|
||||
extracted_vectors = self._extract_vectors_from_doc(doc)
|
||||
for vec in extracted_vectors:
|
||||
# Validate consistency of dimensions
|
||||
current_dim = len(vec)
|
||||
if expected_dim is None:
|
||||
expected_dim = current_dim
|
||||
elif current_dim != expected_dim:
|
||||
logger.warning(
|
||||
f"Skipping vector with mismatched dimension: {current_dim} (expected {expected_dim})")
|
||||
continue
|
||||
|
||||
temp_vectors.append(vec)
|
||||
temp_meta.append(doc)
|
||||
|
||||
# 3. Handle empty datasets gracefully
|
||||
if not temp_vectors:
|
||||
self._index_matrix = np.empty((0, 0), dtype=float)
|
||||
self._meta_docs = []
|
||||
logger.info("Rebuild finished: No vectors found.")
|
||||
return SafeResult.ok({"count": 0, "dim": 0})
|
||||
|
||||
# 4. Convert to NumPy matrix for fast similarity math
|
||||
matrix = np.array(temp_vectors, dtype=float)
|
||||
|
||||
# Final safety check for malformed numpy arrays (e.g., ragged nested sequences)
|
||||
if matrix.dtype == object:
|
||||
return SafeResult.fail("Failed to build index: Vectors have inconsistent lengths.")
|
||||
|
||||
self._index_matrix = matrix
|
||||
self._meta_docs = temp_meta
|
||||
|
||||
logger.info(f"Index rebuilt successfully: {matrix.shape[0]} vectors, {matrix.shape[1]} dimensions.")
|
||||
return SafeResult.ok({
|
||||
"count": int(matrix.shape[0]),
|
||||
"dim": int(matrix.shape[1])
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to rebuild local vector index")
|
||||
return SafeResult.fail(f"Rebuild index error: {str(e)}")
|
||||
@@ -0,0 +1,167 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from quart import Response, jsonify
|
||||
|
||||
from .data_processor import DataProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SafeResult:
|
||||
"""Unified, BSON-safe, Quart-compatible response wrapper."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
success: bool,
|
||||
data: Optional[Any] = None,
|
||||
message: Optional[str] = None,
|
||||
error: Optional[Union[str, Dict[str, Any]]] = None,
|
||||
status_code: Optional[int] = None,
|
||||
_raw_data: Optional[Any] = None,
|
||||
):
|
||||
self.success = success
|
||||
self._raw_data = _raw_data if _raw_data is not None else data
|
||||
self.data = DataProcessor.to_json_compatible(data)
|
||||
self.message = message or ("Success" if success else "Error")
|
||||
self.error = DataProcessor.to_json_compatible(error)
|
||||
self.status_code = status_code or (200 if success else 400)
|
||||
|
||||
logger.debug(
|
||||
"[SafeResult.__init__] success=%s, status=%s, message=%s",
|
||||
self.success,
|
||||
self.status_code,
|
||||
self.message,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def ok(
|
||||
cls,
|
||||
data: Optional[Any] = None,
|
||||
message: Optional[str] = "OK",
|
||||
status_code: int = 200,
|
||||
) -> "SafeResult":
|
||||
return cls(
|
||||
True,
|
||||
data=data,
|
||||
message=message,
|
||||
status_code=status_code,
|
||||
_raw_data=data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def fail(
|
||||
cls,
|
||||
error: Optional[Union[str, Dict[str, Any], Exception]] = "Error",
|
||||
data: Optional[Any] = None,
|
||||
status_code: int = 400,
|
||||
message: Optional[str] = None,
|
||||
) -> "SafeResult":
|
||||
err_val = str(error) if isinstance(error, Exception) else error
|
||||
msg_val = message or (str(err_val) if isinstance(err_val, str) else "Error")
|
||||
|
||||
return cls(
|
||||
False,
|
||||
data=data,
|
||||
message=msg_val,
|
||||
error=err_val,
|
||||
status_code=status_code,
|
||||
_raw_data=data,
|
||||
)
|
||||
|
||||
def original(self) -> Any:
|
||||
return self._apply_keymap(self._raw_data)
|
||||
|
||||
def _apply_keymap(self, obj: Any) -> Any:
|
||||
if isinstance(obj, list):
|
||||
return [self._apply_keymap(item) for item in obj]
|
||||
|
||||
if isinstance(obj, dict):
|
||||
keymap = obj.get("__keymap")
|
||||
new_obj: Dict[str, Any] = {}
|
||||
for k, v in obj.items():
|
||||
if k == "__keymap":
|
||||
continue
|
||||
val = self._apply_keymap(v)
|
||||
if keymap and k in keymap:
|
||||
new_obj[keymap[k]] = val
|
||||
else:
|
||||
new_obj[k] = val
|
||||
return new_obj
|
||||
|
||||
return obj
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": self.success,
|
||||
"data": self.data,
|
||||
"message": self.message,
|
||||
"error": self.error,
|
||||
"status_code": self.status_code,
|
||||
}
|
||||
|
||||
def model_dump(self) -> Dict[str, Any]:
|
||||
return self.to_dict()
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_dict(), default=str)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"SafeResult(success={self.success}, data={self.data}, "
|
||||
f"message={self.message}, error={self.error})"
|
||||
)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.success
|
||||
|
||||
def to_response(self, override_status: Optional[int] = None):
|
||||
code = override_status or self.status_code
|
||||
return jsonify(self.to_dict()), code
|
||||
|
||||
@classmethod
|
||||
async def from_quart_response(cls, resp: Union[Response, tuple]) -> "SafeResult":
|
||||
try:
|
||||
if isinstance(resp, tuple):
|
||||
resp_obj, code = resp
|
||||
else:
|
||||
resp_obj = resp
|
||||
code = getattr(resp, "status_code", 200)
|
||||
|
||||
body = await resp_obj.get_data(as_text=True)
|
||||
parsed = json.loads(body) if body else {}
|
||||
|
||||
if isinstance(parsed, dict) and "success" in parsed:
|
||||
return cls(
|
||||
success=parsed.get("success", False),
|
||||
data=parsed.get("data"),
|
||||
message=parsed.get("message"),
|
||||
error=parsed.get("error"),
|
||||
status_code=parsed.get("status_code", code),
|
||||
)
|
||||
|
||||
return cls(
|
||||
success=(200 <= code < 300),
|
||||
data=parsed,
|
||||
message=f"Parsed response ({code})",
|
||||
status_code=code,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return cls.fail(str(e), status_code=500, message="Response parsing failed")
|
||||
|
||||
def log(self, prefix: str = "") -> "SafeResult":
|
||||
msg = f"{prefix} [{self.status_code}] {self.message}"
|
||||
if self.success:
|
||||
logger.info("[SafeResult.log] ✅ %s", msg)
|
||||
else:
|
||||
logger.error("[SafeResult.log] ❌ %s | %s", msg, self.error)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def ensure(cls, condition: bool, message: str = "Condition failed", **kwargs) -> "SafeResult":
|
||||
if condition:
|
||||
success_message = kwargs.pop("message", "OK")
|
||||
return cls.ok(message=success_message, **kwargs)
|
||||
return cls.fail(error=message, **kwargs)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
ZEmbedder – Local BGE-M3 Hybrid Embedding manager for Blackwell RTX 5080.
|
||||
Integrates with ZMongo, LocalVectorSearch, and SafeResult.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pymilvus.model.hybrid import BGEM3EmbeddingFunction
|
||||
|
||||
# Internal toolbag imports
|
||||
from .data_processor import DataProcessor
|
||||
from .safe_result import SafeResult
|
||||
from .zmongo import ZMongo
|
||||
from .local_vector_search import LocalVectorSearch
|
||||
|
||||
# Load environment resources
|
||||
RESOURCE_PATH = Path.home() / ".resources"
|
||||
load_dotenv(RESOURCE_PATH / ".env")
|
||||
load_dotenv(RESOURCE_PATH / ".secrets")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Constants for BGE-M3
|
||||
DEFAULT_MODEL_PATH = "/home/comfyuser/.resources/preserved_models/encoders/bge-m3"
|
||||
DEFAULT_DB_NAME = "wiki_kb"
|
||||
class ZEmbedder:
|
||||
"""
|
||||
Local BGE-M3 Embedding manager.
|
||||
Produces Dense and Sparse vectors for hybrid RAG pipelines.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
db_name: str = DEFAULT_DB_NAME,
|
||||
model_path: str = DEFAULT_MODEL_PATH,
|
||||
device: str = "cuda:0",
|
||||
output_dimensionality: int = 1024 # BGE-M3 default dense dim
|
||||
):
|
||||
self.repository = ZMongo(db_name=db_name)
|
||||
self.device = device
|
||||
self.default_output_dim = output_dimensionality
|
||||
|
||||
# Verify local model path
|
||||
if not os.path.exists(model_path):
|
||||
logger.error(f"❌ Model not found at {model_path}. Please download it first.")
|
||||
raise FileNotFoundError(f"BGE-M3 not found at {model_path}")
|
||||
|
||||
# Initialize the Hybrid Model on Blackwell GPU
|
||||
self.model = BGEM3EmbeddingFunction(
|
||||
model_name=model_path,
|
||||
device=device,
|
||||
use_fp16=True # Optimized for RTX 5080
|
||||
)
|
||||
|
||||
# Internal searcher for backward compatibility
|
||||
self.vector_search = LocalVectorSearch(
|
||||
repository=self.repository,
|
||||
collection="knowledge_base",
|
||||
embedding_field="embedding.dense",
|
||||
vector_key="vectors"
|
||||
)
|
||||
logger.info(f"✅ ZEmbedder (BGE-M3) initialized on {device}")
|
||||
|
||||
@staticmethod
|
||||
def _to_dense_list(vec: Any) -> List[float]:
|
||||
if hasattr(vec, "tolist"):
|
||||
return vec.tolist()
|
||||
return list(vec)
|
||||
|
||||
@staticmethod
|
||||
def _to_sparse_dict(sparse: Any) -> Dict[str, float]:
|
||||
"""
|
||||
Normalize BGE-M3 sparse output into a plain dict[str, float].
|
||||
Handles dict-like outputs and scipy sparse-style objects.
|
||||
"""
|
||||
if sparse is None:
|
||||
return {}
|
||||
|
||||
if isinstance(sparse, dict):
|
||||
return {str(k): float(v) for k, v in sparse.items()}
|
||||
|
||||
# scipy sparse row / matrix style
|
||||
if hasattr(sparse, "tocoo"):
|
||||
coo = sparse.tocoo()
|
||||
return {str(int(col)): float(val) for col, val in zip(coo.col, coo.data)}
|
||||
|
||||
# some libs may expose indices/data separately
|
||||
indices = getattr(sparse, "indices", None)
|
||||
data = getattr(sparse, "data", None)
|
||||
if indices is not None and data is not None:
|
||||
return {str(int(i)): float(v) for i, v in zip(indices, data)}
|
||||
|
||||
raise TypeError(f"Unsupported sparse vector type: {type(sparse)!r}")
|
||||
|
||||
async def embed_many(self, texts: List[str]) -> SafeResult:
|
||||
"""Embeds multiple texts using hybrid logic."""
|
||||
try:
|
||||
if not texts:
|
||||
return SafeResult.ok({"dense": [], "sparse": [], "count": 0})
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
embeddings = await loop.run_in_executor(
|
||||
None,
|
||||
self.model.encode_documents,
|
||||
texts,
|
||||
)
|
||||
|
||||
dense_vectors = [self._to_dense_list(vec) for vec in embeddings["dense"]]
|
||||
sparse_vectors = [self._to_sparse_dict(vec) for vec in embeddings["sparse"]]
|
||||
|
||||
return SafeResult.ok({
|
||||
"dense": dense_vectors,
|
||||
"sparse": sparse_vectors,
|
||||
"count": len(texts),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Batch embedding failed")
|
||||
return SafeResult.fail(e)
|
||||
|
||||
async def get_embedding(
|
||||
self,
|
||||
text: Optional[str] = None,
|
||||
*,
|
||||
collection: Optional[str] = None,
|
||||
document_id: Optional[Any] = None,
|
||||
text_field: str = "text",
|
||||
embedding_field: str = "embedding",
|
||||
**kwargs,
|
||||
) -> SafeResult:
|
||||
"""
|
||||
Fetches or embeds text. Returns a SafeResult containing
|
||||
BSON-safe dense and sparse vectors.
|
||||
"""
|
||||
try:
|
||||
if not text and collection and document_id is not None:
|
||||
fetch_res = await self.repository.find_one_async(
|
||||
collection,
|
||||
{"_id": document_id},
|
||||
)
|
||||
if not fetch_res.success:
|
||||
return fetch_res
|
||||
|
||||
text = DataProcessor.get_value(fetch_res.data, text_field)
|
||||
|
||||
if not text:
|
||||
return SafeResult.fail("No text content provided or found.")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
embs = await loop.run_in_executor(
|
||||
None,
|
||||
self.model.encode_queries,
|
||||
[text],
|
||||
)
|
||||
|
||||
dense_vec = self._to_dense_list(embs["dense"][0])
|
||||
sparse_vec = self._to_sparse_dict(embs["sparse"][0])
|
||||
|
||||
if collection and document_id is not None:
|
||||
update_payload = {
|
||||
text_field: text,
|
||||
embedding_field: {
|
||||
"dense": dense_vec,
|
||||
"sparse": sparse_vec,
|
||||
"model": "bge-m3-local",
|
||||
"dimensionality": len(dense_vec),
|
||||
},
|
||||
}
|
||||
|
||||
persist_res = await self.repository.insert_or_update(
|
||||
collection,
|
||||
{"_id": document_id},
|
||||
update_payload,
|
||||
)
|
||||
if not persist_res.success:
|
||||
return persist_res
|
||||
|
||||
return SafeResult.ok({
|
||||
"dense": dense_vec,
|
||||
"sparse": sparse_vec,
|
||||
"text": text,
|
||||
"dimensionality": len(dense_vec),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Single embedding generation failed")
|
||||
return SafeResult.fail(e)
|
||||
|
||||
def close(self):
|
||||
"""Cleanup resources."""
|
||||
if hasattr(self, "repository"):
|
||||
self.repository.close()
|
||||
if hasattr(self, "vector_search"):
|
||||
self.vector_search.clear_index()
|
||||
|
||||
|
||||
# --- Async Demo ---
|
||||
if __name__ == "__main__":
|
||||
async def run_demo():
|
||||
embedder = ZEmbedder()
|
||||
|
||||
test_text = "Legal compliance for AI Blackwell architecture."
|
||||
res = await embedder.get_embedding(text=test_text)
|
||||
|
||||
if res.success:
|
||||
print(f"✅ Successfully generated hybrid embeddings.")
|
||||
print(f"Dense Dim: {res.data['dimensionality']}")
|
||||
print(f"Sparse Keys: {list(res.data['sparse'].keys())[:5]}...")
|
||||
|
||||
embedder.close()
|
||||
|
||||
asyncio.run(run_demo())
|
||||
@@ -0,0 +1,833 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from bson import ObjectId
|
||||
from dotenv import load_dotenv
|
||||
from motor import motor_asyncio
|
||||
from pymongo.errors import PyMongoError
|
||||
|
||||
from .data_processor import DataProcessor
|
||||
from .safe_result import SafeResult
|
||||
|
||||
ENV_PATH = Path.home() / ".resources" / ".env"
|
||||
load_dotenv(ENV_PATH)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZMongo:
|
||||
"""Production-oriented MongoDB helper with SafeResult-based responses."""
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uri: Optional[str] = None,
|
||||
db_name: Optional[str] = None,
|
||||
*,
|
||||
cache_enabled: bool = True,
|
||||
cache_ttl_seconds: int = 5,
|
||||
run_sync_timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
self.uri = uri or os.getenv("MONGO_URI", "mongodb://127.0.0.1:27017")
|
||||
self.db_name = db_name or os.getenv("MONGO_DATABASE_NAME", "test")
|
||||
|
||||
self.cache_enabled = cache_enabled
|
||||
self.cache_ttl_seconds = max(0, int(cache_ttl_seconds))
|
||||
self.run_sync_timeout_seconds = float(run_sync_timeout_seconds)
|
||||
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._loop_ready = threading.Event()
|
||||
self._closed = False
|
||||
self._close_lock = threading.Lock()
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._run_event_loop,
|
||||
name=f"ZMongoLoop-{id(self)}",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
self._loop_ready.wait(timeout=5)
|
||||
|
||||
self._async_clients: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, motor_asyncio.AsyncIOMotorClient]" = (
|
||||
weakref.WeakKeyDictionary()
|
||||
)
|
||||
|
||||
self._cache: Dict[str, Dict[str, Tuple[Any, float]]] = {}
|
||||
self._cache_lock = threading.RLock()
|
||||
|
||||
def _run_event_loop(self) -> None:
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop_ready.set()
|
||||
try:
|
||||
self._loop.run_forever()
|
||||
except Exception:
|
||||
logger.exception("ZMongo background event loop crashed")
|
||||
|
||||
def _ensure_not_closed(self) -> None:
|
||||
if self._closed:
|
||||
raise RuntimeError("ZMongo instance is closed")
|
||||
|
||||
def close(self) -> None:
|
||||
with self._close_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
|
||||
try:
|
||||
for client in list(self._async_clients.values()):
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
logger.debug("Failed closing Mongo client", exc_info=True)
|
||||
except Exception:
|
||||
logger.debug("Failed iterating Mongo clients during close", exc_info=True)
|
||||
|
||||
with self._cache_lock:
|
||||
self._cache.clear()
|
||||
|
||||
try:
|
||||
if self._loop.is_running():
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
except Exception:
|
||||
logger.debug("Failed stopping event loop", exc_info=True)
|
||||
|
||||
try:
|
||||
if self._thread.is_alive():
|
||||
self._thread.join(timeout=2.0)
|
||||
except Exception:
|
||||
logger.debug("Failed joining background thread", exc_info=True)
|
||||
|
||||
try:
|
||||
if not self._loop.is_closed():
|
||||
self._loop.close()
|
||||
except Exception:
|
||||
logger.debug("Failed closing event loop", exc_info=True)
|
||||
|
||||
def __enter__(self) -> "ZMongo":
|
||||
self._ensure_not_closed()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
self.close()
|
||||
|
||||
async def __aenter__(self) -> "ZMongo":
|
||||
self._ensure_not_closed()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
self.close()
|
||||
|
||||
def _client_for_current_loop(self) -> motor_asyncio.AsyncIOMotorClient:
|
||||
self._ensure_not_closed()
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = self._loop
|
||||
|
||||
client = self._async_clients.get(loop)
|
||||
if client is None:
|
||||
client = motor_asyncio.AsyncIOMotorClient(self.uri)
|
||||
self._async_clients[loop] = client
|
||||
return client
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
return self._client_for_current_loop()[self.db_name]
|
||||
|
||||
@staticmethod
|
||||
def _exception_payload(exc: Exception, operation: Optional[str] = None) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"error_type": exc.__class__.__name__,
|
||||
"error": str(exc),
|
||||
}
|
||||
if operation:
|
||||
payload["operation"] = operation
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def _fail(
|
||||
cls,
|
||||
exc: Exception,
|
||||
*,
|
||||
operation: Optional[str] = None,
|
||||
status_code: int = 500,
|
||||
message: Optional[str] = None,
|
||||
data: Optional[Any] = None,
|
||||
) -> SafeResult:
|
||||
payload = cls._exception_payload(exc, operation=operation)
|
||||
return SafeResult.fail(
|
||||
error=payload,
|
||||
data=data,
|
||||
status_code=status_code,
|
||||
message=message or str(exc),
|
||||
)
|
||||
|
||||
def _normalize_query(self, query: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
query = dict(query or {})
|
||||
if "_id" in query and isinstance(query["_id"], str) and ObjectId.is_valid(query["_id"]):
|
||||
query["_id"] = ObjectId(query["_id"])
|
||||
return query
|
||||
|
||||
@staticmethod
|
||||
def _json_key(obj: Any) -> str:
|
||||
safe_obj = DataProcessor.to_json_compatible(obj)
|
||||
return json.dumps(safe_obj, sort_keys=True, default=str, separators=(",", ":"))
|
||||
|
||||
def _make_cache_key(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
coll: str,
|
||||
query: Optional[Dict[str, Any]] = None,
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
payload = {
|
||||
"operation": operation,
|
||||
"collection": coll,
|
||||
"query": self._normalize_query(query),
|
||||
"extra": extra or {},
|
||||
}
|
||||
return self._json_key(payload)
|
||||
|
||||
def _get_cached(self, coll: str, key: str) -> Optional[Any]:
|
||||
if not self.cache_enabled or self.cache_ttl_seconds <= 0:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
with self._cache_lock:
|
||||
coll_cache = self._cache.get(coll)
|
||||
if not coll_cache:
|
||||
return None
|
||||
|
||||
entry = coll_cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
value, expires_at = entry
|
||||
if now >= expires_at:
|
||||
coll_cache.pop(key, None)
|
||||
if not coll_cache:
|
||||
self._cache.pop(coll, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
def _set_cached(self, coll: str, key: str, value: Any) -> None:
|
||||
if not self.cache_enabled or self.cache_ttl_seconds <= 0:
|
||||
return
|
||||
|
||||
expires_at = time.time() + self.cache_ttl_seconds
|
||||
with self._cache_lock:
|
||||
self._cache.setdefault(coll, {})[key] = (value, expires_at)
|
||||
|
||||
def clear_cache(self, coll: Optional[str] = None) -> None:
|
||||
with self._cache_lock:
|
||||
if coll is None:
|
||||
self._cache.clear()
|
||||
else:
|
||||
self._cache.pop(coll, None)
|
||||
|
||||
def run_sync(self, coro_or_factory, *args, timeout: Optional[float] = None, **kwargs) -> SafeResult:
|
||||
self._ensure_not_closed()
|
||||
|
||||
if asyncio.iscoroutine(coro_or_factory):
|
||||
coro = coro_or_factory
|
||||
else:
|
||||
coro = coro_or_factory(*args, **kwargs)
|
||||
|
||||
if not asyncio.iscoroutine(coro):
|
||||
return SafeResult.fail(
|
||||
error={
|
||||
"error_type": "TypeError",
|
||||
"error": "run_sync expected a coroutine or coroutine factory",
|
||||
},
|
||||
status_code=500,
|
||||
message="run_sync misuse",
|
||||
)
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
|
||||
try:
|
||||
result = future.result(timeout=timeout or self.run_sync_timeout_seconds)
|
||||
if isinstance(result, SafeResult):
|
||||
return result
|
||||
return SafeResult.ok(result)
|
||||
except asyncio.TimeoutError as exc:
|
||||
future.cancel()
|
||||
return self._fail(exc, operation="run_sync", status_code=504, message="Mongo operation timed out")
|
||||
except Exception as exc:
|
||||
future.cancel()
|
||||
return self._fail(exc, operation="run_sync", status_code=500)
|
||||
|
||||
async def find_one_async(
|
||||
self,
|
||||
coll: str,
|
||||
query: Dict[str, Any],
|
||||
*,
|
||||
cache: bool = False,
|
||||
**kwargs,
|
||||
) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
normalized_query = self._normalize_query(query)
|
||||
|
||||
cache_key = self._make_cache_key(
|
||||
operation="find_one",
|
||||
coll=coll,
|
||||
query=normalized_query,
|
||||
extra={"kwargs": kwargs},
|
||||
)
|
||||
|
||||
if cache:
|
||||
cached = self._get_cached(coll, cache_key)
|
||||
if cached is not None:
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"document": cached,
|
||||
"cache_hit": True,
|
||||
"collection": coll,
|
||||
"query": normalized_query,
|
||||
}
|
||||
)
|
||||
|
||||
doc = await self.db[coll].find_one(normalized_query, **kwargs)
|
||||
|
||||
if cache and doc is not None:
|
||||
self._set_cached(coll, cache_key, doc)
|
||||
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"document": doc,
|
||||
"cache_hit": False,
|
||||
"collection": coll,
|
||||
"query": normalized_query,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="find_one")
|
||||
|
||||
async def find_many_async(
|
||||
self,
|
||||
coll: str,
|
||||
query: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
sort: Optional[Union[List[Tuple[str, int]], Tuple[str, int]]] = None,
|
||||
limit: Optional[int] = 1000,
|
||||
cache: bool = False,
|
||||
) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
normalized_query = self._normalize_query(query or {})
|
||||
|
||||
cache_key = self._make_cache_key(
|
||||
operation="find_many",
|
||||
coll=coll,
|
||||
query=normalized_query,
|
||||
extra={"sort": sort, "limit": limit},
|
||||
)
|
||||
|
||||
if cache:
|
||||
cached = self._get_cached(coll, cache_key)
|
||||
if cached is not None:
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"documents": cached,
|
||||
"count": len(cached),
|
||||
"cache_hit": True,
|
||||
"collection": coll,
|
||||
"query": normalized_query,
|
||||
}
|
||||
)
|
||||
|
||||
cursor = self.db[coll].find(normalized_query)
|
||||
if sort:
|
||||
cursor = cursor.sort(sort)
|
||||
if limit is not None:
|
||||
cursor = cursor.limit(limit)
|
||||
|
||||
docs = await cursor.to_list(length=limit)
|
||||
if cache:
|
||||
self._set_cached(coll, cache_key, docs)
|
||||
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"documents": docs,
|
||||
"count": len(docs),
|
||||
"cache_hit": False,
|
||||
"collection": coll,
|
||||
"query": normalized_query,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="find_many")
|
||||
|
||||
async def aggregate_async(self, coll: str, pipeline: List[Dict[str, Any]]) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
cursor = self.db[coll].aggregate(pipeline)
|
||||
docs = await cursor.to_list(length=None)
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"documents": docs,
|
||||
"count": len(docs),
|
||||
"collection": coll,
|
||||
"pipeline": pipeline,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="aggregate")
|
||||
|
||||
async def count_documents_async(
|
||||
self,
|
||||
coll: str,
|
||||
query: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
cache: bool = False,
|
||||
) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
normalized_query = self._normalize_query(query or {})
|
||||
|
||||
cache_key = self._make_cache_key(
|
||||
operation="count_documents",
|
||||
coll=coll,
|
||||
query=normalized_query,
|
||||
)
|
||||
|
||||
if cache:
|
||||
cached = self._get_cached(coll, cache_key)
|
||||
if cached is not None:
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"count": cached,
|
||||
"cache_hit": True,
|
||||
"collection": coll,
|
||||
"query": normalized_query,
|
||||
}
|
||||
)
|
||||
|
||||
count = await self.db[coll].count_documents(normalized_query)
|
||||
if cache:
|
||||
self._set_cached(coll, cache_key, count)
|
||||
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"count": count,
|
||||
"cache_hit": False,
|
||||
"collection": coll,
|
||||
"query": normalized_query,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="count_documents")
|
||||
|
||||
async def insert_one_async(self, coll: str, doc: Dict[str, Any]) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
result = await self.db[coll].insert_one(doc)
|
||||
self.clear_cache(coll)
|
||||
return SafeResult.ok({"inserted_id": result.inserted_id, "collection": coll})
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="insert_one")
|
||||
|
||||
async def insert_many_async(self, coll: str, docs: List[Dict[str, Any]]) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
result = await self.db[coll].insert_many(docs)
|
||||
self.clear_cache(coll)
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"inserted_ids": result.inserted_ids,
|
||||
"inserted_count": len(result.inserted_ids),
|
||||
"collection": coll,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="insert_many")
|
||||
|
||||
async def update_one_async(
|
||||
self,
|
||||
coll: str,
|
||||
query: Dict[str, Any],
|
||||
update: Dict[str, Any],
|
||||
*,
|
||||
upsert: bool = False,
|
||||
) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
normalized_query = self._normalize_query(query)
|
||||
update_doc = update if any(k.startswith("$") for k in update.keys()) else {"$set": update}
|
||||
|
||||
result = await self.db[coll].update_one(normalized_query, update_doc, upsert=upsert)
|
||||
self.clear_cache(coll)
|
||||
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"matched_count": result.matched_count,
|
||||
"modified_count": result.modified_count,
|
||||
"upserted_id": result.upserted_id,
|
||||
"collection": coll,
|
||||
"query": normalized_query,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="update_one")
|
||||
|
||||
async def update_many_async(
|
||||
self,
|
||||
coll: str,
|
||||
query_or_ops: Union[Dict[str, Any], List[Any]],
|
||||
update: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
upsert: bool = False,
|
||||
) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
|
||||
if isinstance(query_or_ops, list):
|
||||
result = await self.db[coll].bulk_write(query_or_ops)
|
||||
self.clear_cache(coll)
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"bulk_api_result": getattr(result, "bulk_api_result", None),
|
||||
"inserted_count": getattr(result, "inserted_count", None),
|
||||
"matched_count": getattr(result, "matched_count", None),
|
||||
"modified_count": getattr(result, "modified_count", None),
|
||||
"deleted_count": getattr(result, "deleted_count", None),
|
||||
"upserted_count": getattr(result, "upserted_count", None),
|
||||
"collection": coll,
|
||||
}
|
||||
)
|
||||
|
||||
normalized_query = self._normalize_query(query_or_ops)
|
||||
if update is None:
|
||||
return SafeResult.fail(
|
||||
error={"error_type": "ValueError", "error": "update is required for non-bulk update_many"},
|
||||
status_code=400,
|
||||
message="Missing update document",
|
||||
)
|
||||
|
||||
update_doc = update if any(k.startswith("$") for k in update.keys()) else {"$set": update}
|
||||
result = await self.db[coll].update_many(normalized_query, update_doc, upsert=upsert)
|
||||
self.clear_cache(coll)
|
||||
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"matched_count": result.matched_count,
|
||||
"modified_count": result.modified_count,
|
||||
"collection": coll,
|
||||
"query": normalized_query,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="update_many")
|
||||
|
||||
async def delete_one_async(self, coll: str, query: Dict[str, Any]) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
normalized_query = self._normalize_query(query)
|
||||
result = await self.db[coll].delete_one(normalized_query)
|
||||
self.clear_cache(coll)
|
||||
return SafeResult.ok(
|
||||
{"deleted_count": result.deleted_count, "collection": coll, "query": normalized_query}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="delete_one")
|
||||
|
||||
async def delete_many_async(self, coll: str, query: Dict[str, Any]) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
normalized_query = self._normalize_query(query)
|
||||
result = await self.db[coll].delete_many(normalized_query)
|
||||
self.clear_cache(coll)
|
||||
return SafeResult.ok(
|
||||
{"deleted_count": result.deleted_count, "collection": coll, "query": normalized_query}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="delete_many")
|
||||
|
||||
async def insert_or_update_async(
|
||||
self,
|
||||
coll: str,
|
||||
query_or_document: Dict[str, Any],
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
|
||||
if data is None:
|
||||
if "_id" not in query_or_document:
|
||||
return SafeResult.fail(
|
||||
error={"error_type": "ValueError", "error": "Document must include _id when data is omitted"},
|
||||
status_code=400,
|
||||
message="Missing _id for insert_or_update",
|
||||
)
|
||||
|
||||
query = self._normalize_query({"_id": query_or_document["_id"]})
|
||||
update_doc = {"$set": {k: v for k, v in query_or_document.items() if k != "_id"}}
|
||||
else:
|
||||
query = self._normalize_query(query_or_document)
|
||||
update_doc = data if any(k.startswith("$") for k in data.keys()) else {"$set": data}
|
||||
|
||||
result = await self.db[coll].update_one(query, update_doc, upsert=True)
|
||||
self.clear_cache(coll)
|
||||
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"matched_count": result.matched_count,
|
||||
"modified_count": result.modified_count,
|
||||
"upserted_id": result.upserted_id,
|
||||
"collection": coll,
|
||||
"query": query,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="insert_or_update")
|
||||
|
||||
async def save_value_async(
|
||||
self,
|
||||
coll: str,
|
||||
value: Any,
|
||||
*,
|
||||
query: Optional[Dict[str, Any]] = None,
|
||||
field_path: Optional[str] = None,
|
||||
upsert: bool = True,
|
||||
parse_json_strings: bool = True,
|
||||
normalize_for_storage: bool = False,
|
||||
) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
|
||||
normalized_query = self._normalize_query(query or {})
|
||||
parsed_value = value
|
||||
|
||||
if parse_json_strings and isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped:
|
||||
try:
|
||||
parsed_value = json.loads(stripped)
|
||||
except Exception:
|
||||
parsed_value = value
|
||||
|
||||
if normalize_for_storage:
|
||||
parsed_value = DataProcessor.to_json_compatible(parsed_value)
|
||||
|
||||
if not normalized_query and isinstance(parsed_value, dict) and parsed_value.get("_id") is not None:
|
||||
normalized_query = self._normalize_query({"_id": parsed_value["_id"]})
|
||||
|
||||
if field_path and str(field_path).strip():
|
||||
update_payload = {str(field_path).strip(): parsed_value}
|
||||
elif isinstance(parsed_value, dict):
|
||||
update_payload = dict(parsed_value)
|
||||
update_payload.pop("_id", None)
|
||||
else:
|
||||
update_payload = {"value": parsed_value}
|
||||
|
||||
if not normalized_query and not upsert:
|
||||
return SafeResult.fail(
|
||||
error={"error_type": "ValueError", "error": "No query provided and upsert is False"},
|
||||
status_code=400,
|
||||
message="Cannot determine target document",
|
||||
)
|
||||
|
||||
if not normalized_query:
|
||||
result = await self.db[coll].insert_one(update_payload)
|
||||
self.clear_cache(coll)
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"operation": "inserted_new",
|
||||
"inserted_id": result.inserted_id,
|
||||
"collection": coll,
|
||||
"field_path": field_path,
|
||||
"saved_value": parsed_value,
|
||||
}
|
||||
)
|
||||
|
||||
result = await self.db[coll].update_one(
|
||||
normalized_query,
|
||||
{"$set": update_payload},
|
||||
upsert=upsert,
|
||||
)
|
||||
self.clear_cache(coll)
|
||||
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"operation": "inserted_via_upsert" if result.upserted_id is not None else "updated_existing",
|
||||
"matched_count": result.matched_count,
|
||||
"modified_count": result.modified_count,
|
||||
"upserted_id": result.upserted_id,
|
||||
"collection": coll,
|
||||
"query": normalized_query,
|
||||
"field_path": field_path,
|
||||
"saved_value": parsed_value,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="save_value")
|
||||
|
||||
async def list_collections_async(self) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
names = await self.db.list_collection_names()
|
||||
return SafeResult.ok({"collections": names, "count": len(names)})
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="list_collections")
|
||||
|
||||
async def drop_database_async(self, database_name: Optional[str] = None) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
name = database_name or self.db_name
|
||||
await self._client_for_current_loop().drop_database(name)
|
||||
self.clear_cache()
|
||||
return SafeResult.ok({"dropped_database": name})
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="drop_database")
|
||||
|
||||
async def ping_async(self) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
start = time.time()
|
||||
await self.db.command({"ping": 1})
|
||||
end = time.time()
|
||||
return SafeResult.ok({"ok": True, "latency_seconds": end - start, "database": self.db_name})
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="ping")
|
||||
|
||||
async def sync_timestamp_async(self) -> SafeResult:
|
||||
try:
|
||||
self._ensure_not_closed()
|
||||
start = time.time()
|
||||
|
||||
try:
|
||||
result = await self.db.command({"hello": 1})
|
||||
except PyMongoError:
|
||||
result = await self.db.command({"isMaster": 1})
|
||||
|
||||
end = time.time()
|
||||
server_time = result.get("localTime")
|
||||
if server_time and server_time.tzinfo is None:
|
||||
server_time = server_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
return SafeResult.ok(
|
||||
{
|
||||
"server_time": server_time,
|
||||
"latency_seconds": end - start,
|
||||
"offset_seconds": (
|
||||
(server_time - datetime.now(timezone.utc)).total_seconds() if server_time is not None else None
|
||||
),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._fail(exc, operation="sync_timestamp")
|
||||
|
||||
def find_one(self, coll: str, query: Dict[str, Any], *, cache: bool = False, **kwargs) -> SafeResult:
|
||||
return self.run_sync(self.find_one_async, coll, query, cache=cache, **kwargs)
|
||||
|
||||
def find_many(
|
||||
self,
|
||||
coll: str,
|
||||
query: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
sort: Optional[Union[List[Tuple[str, int]], Tuple[str, int]]] = None,
|
||||
limit: Optional[int] = 1000,
|
||||
cache: bool = False,
|
||||
) -> SafeResult:
|
||||
return self.run_sync(self.find_many_async, coll, query, sort=sort, limit=limit, cache=cache)
|
||||
|
||||
def aggregate(self, coll: str, pipeline: List[Dict[str, Any]]) -> SafeResult:
|
||||
return self.run_sync(self.aggregate_async, coll, pipeline)
|
||||
|
||||
def count_documents(self, coll: str, query: Optional[Dict[str, Any]] = None, *, cache: bool = False) -> SafeResult:
|
||||
return self.run_sync(self.count_documents_async, coll, query, cache=cache)
|
||||
|
||||
def insert_one(self, coll: str, doc: Dict[str, Any]) -> SafeResult:
|
||||
return self.run_sync(self.insert_one_async, coll, doc)
|
||||
|
||||
def insert_many(self, coll: str, docs: List[Dict[str, Any]]) -> SafeResult:
|
||||
return self.run_sync(self.insert_many_async, coll, docs)
|
||||
|
||||
def update_one(
|
||||
self,
|
||||
coll: str,
|
||||
query: Dict[str, Any],
|
||||
update: Dict[str, Any],
|
||||
*,
|
||||
upsert: bool = False,
|
||||
) -> SafeResult:
|
||||
return self.run_sync(self.update_one_async, coll, query, update, upsert=upsert)
|
||||
|
||||
def update_many(
|
||||
self,
|
||||
coll: str,
|
||||
query_or_ops: Union[Dict[str, Any], List[Any]],
|
||||
update: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
upsert: bool = False,
|
||||
) -> SafeResult:
|
||||
return self.run_sync(self.update_many_async, coll, query_or_ops, update, upsert=upsert)
|
||||
|
||||
def delete_one(self, coll: str, query: Dict[str, Any]) -> SafeResult:
|
||||
return self.run_sync(self.delete_one_async, coll, query)
|
||||
|
||||
def delete_many(self, coll: str, query: Dict[str, Any]) -> SafeResult:
|
||||
return self.run_sync(self.delete_many_async, coll, query)
|
||||
|
||||
def delete_all_documents(self, coll: str) -> SafeResult:
|
||||
return self.run_sync(self.delete_many_async, coll, {})
|
||||
|
||||
def delete_documents(self, coll: str, query: Dict[str, Any]) -> SafeResult:
|
||||
return self.run_sync(self.delete_many_async, coll, query)
|
||||
|
||||
def insert_documents(self, coll: str, docs: List[Dict[str, Any]]) -> SafeResult:
|
||||
return self.run_sync(self.insert_many_async, coll, docs)
|
||||
|
||||
def insert_or_update(
|
||||
self,
|
||||
coll: str,
|
||||
query_or_document: Dict[str, Any],
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
) -> SafeResult:
|
||||
return self.run_sync(self.insert_or_update_async, coll, query_or_document, data)
|
||||
|
||||
def save_value(
|
||||
self,
|
||||
coll: str,
|
||||
value: Any,
|
||||
*,
|
||||
query: Optional[Dict[str, Any]] = None,
|
||||
field_path: Optional[str] = None,
|
||||
upsert: bool = True,
|
||||
parse_json_strings: bool = True,
|
||||
normalize_for_storage: bool = False,
|
||||
) -> SafeResult:
|
||||
return self.run_sync(
|
||||
self.save_value_async,
|
||||
coll,
|
||||
value,
|
||||
query=query,
|
||||
field_path=field_path,
|
||||
upsert=upsert,
|
||||
parse_json_strings=parse_json_strings,
|
||||
normalize_for_storage=normalize_for_storage,
|
||||
)
|
||||
|
||||
def list_collections(self) -> SafeResult:
|
||||
return self.run_sync(self.list_collections_async)
|
||||
|
||||
def drop_database(self, database_name: Optional[str] = None) -> SafeResult:
|
||||
return self.run_sync(self.drop_database_async, database_name)
|
||||
|
||||
def ping(self) -> SafeResult:
|
||||
return self.run_sync(self.ping_async)
|
||||
|
||||
def sync_timestamp(self) -> SafeResult:
|
||||
return self.run_sync(self.sync_timestamp_async)
|
||||
Reference in New Issue
Block a user