recycle_bin.move_to_trash is now the only way the plugin deletes the user's files: SHFileOperationW with FOF_ALLOWUNDO on fixed Windows drives (send2trash or gio elsewhere). Where there is no Recycle Bin (USB stick, network share) it raises TrashUnavailable and nothing is deleted; the API says so (api/trash.trash_failure), including "file in use". Routed through it: model delete (model + sidecars in one operation, or nothing), cover replacement and cover reset, output images, generated audio, recipes with history and images, materials with images, prompt notes, parameter notes, the duplicate copy a physical rename finds and the covers a forced rescan replaces. Temp files, caches and the bounded recipe history are still removed directly. The unused POST /anomalous/clean_civitai_info, which erased every .civitai.info in the library (with the names users set), is removed with its activity-log label. Delete confirmations say where things go. Tests use a fake Recycle Bin (none of their files reach the real one). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
9.0 KiB
Python
240 lines
9.0 KiB
Python
"""Activity log: what Anomalous changed, newest first, kept in the ComfyUI user folder.
|
|
|
|
Two sources feed it. The middleware records this plugin's own write requests
|
|
(and Anomalous_TTS's) after they succeed: files such as models, covers, recipes,
|
|
materials, notes, presets, images and audio. The browser posts the canvas changes
|
|
it made (`POST /anomalous/activity`). Only the newest MAX_ENTRIES are kept and long
|
|
values are shortened, so the file stays small. Recording never fails a request:
|
|
the log is a convenience, not part of the action.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
from aiohttp import web
|
|
import folder_paths
|
|
|
|
from .path_utils import atomic_write_json
|
|
|
|
MAX_ENTRIES = 500
|
|
MAX_LOG_BYTES = 2 * 1024 * 1024 # long canvas entries could otherwise outgrow the JSON writer's limit
|
|
MAX_VALUE_CHARS = 400
|
|
MAX_CHANGES = 60
|
|
|
|
# Write routes worth a line in the log → the action id the browser has words for.
|
|
ROUTE_ACTIONS = {
|
|
'/anomalous/delete_model': 'model_delete',
|
|
'/anomalous/update_metadata': 'model_edit',
|
|
'/anomalous/set_custom_cover': 'model_cover',
|
|
'/anomalous/upload_custom_cover': 'model_cover',
|
|
'/anomalous/scan': 'scan',
|
|
'/anomalous/scan_all': 'scan',
|
|
'/anomalous/save_recipe': 'recipe_save',
|
|
'/anomalous/update_recipe': 'recipe_edit',
|
|
'/anomalous/delete_recipe': 'recipe_delete',
|
|
'/anomalous/restore_recipe_version': 'recipe_restore',
|
|
'/anomalous/set_recipe_gallery_cover': 'recipe_cover',
|
|
'/anomalous/import_recipe_package_commit': 'recipe_import',
|
|
'/anomalous/save_image_material': 'material_save',
|
|
'/anomalous/save_parameter_material': 'material_save',
|
|
'/anomalous/save_prompt_note_material': 'material_save',
|
|
'/anomalous/save_prompt_plan': 'material_save',
|
|
'/anomalous/update_material': 'material_edit',
|
|
'/anomalous/delete_material': 'material_delete',
|
|
'/anomalous/save_notebook': 'note_save',
|
|
'/anomalous/delete_notebook': 'note_delete',
|
|
'/anomalous/save_parameter': 'preset_save',
|
|
'/anomalous/rename_parameter': 'preset_rename',
|
|
'/anomalous/delete_parameter': 'preset_delete',
|
|
'/anomalous/delete_gallery_image': 'image_delete',
|
|
'/anomalous/delete_audio_gallery': 'audio_delete',
|
|
'/anomalous/save_audio_preview': 'audio_save',
|
|
'/anomalous/clear_cache': 'cache_clear',
|
|
'/anomalous_tts/import/commit': 'voice_import',
|
|
'/anomalous_tts/settings': 'voice_settings',
|
|
'/anomalous_tts/storage': 'voice_storage',
|
|
}
|
|
# Request fields that name what was changed, in order of preference.
|
|
TARGET_FIELDS = ('name', 'character', 'filename', 'target_filename', 'recipe_filename',
|
|
'notebook_filename', 'new_name', 'path', 'file', 'target')
|
|
|
|
_lock = threading.RLock()
|
|
_entries = None
|
|
|
|
|
|
def log_path():
|
|
user_dir = folder_paths.get_user_directory() if hasattr(folder_paths, 'get_user_directory') else None
|
|
if not user_dir:
|
|
user_dir = os.path.join(folder_paths.base_path, 'user')
|
|
directory = os.path.join(user_dir, 'anomalous')
|
|
os.makedirs(directory, exist_ok=True)
|
|
return os.path.join(directory, 'activity_log.json')
|
|
|
|
|
|
def _load():
|
|
global _entries
|
|
if _entries is None:
|
|
try:
|
|
with open(log_path(), encoding='utf-8') as source:
|
|
data = json.load(source)
|
|
_entries = [entry for entry in data if isinstance(entry, dict)] if isinstance(data, list) else []
|
|
except (OSError, ValueError):
|
|
_entries = []
|
|
return _entries
|
|
|
|
|
|
def _encoded_size(entries):
|
|
return len(json.dumps(entries, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
|
|
|
|
|
|
def _trim(entries):
|
|
"""Drop the oldest entries until the list fits MAX_ENTRIES and MAX_LOG_BYTES."""
|
|
del entries[MAX_ENTRIES:]
|
|
while len(entries) > 1 and _encoded_size(entries) > MAX_LOG_BYTES:
|
|
del entries[max(1, len(entries) * 9 // 10):]
|
|
|
|
|
|
def _short(value):
|
|
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
|
return text if len(text) <= MAX_VALUE_CHARS else text[:MAX_VALUE_CHARS] + '…'
|
|
|
|
|
|
def add_entry(source, action, target='', detail=None):
|
|
"""Append one entry (newest first) and save. Returns the entry."""
|
|
entry = {
|
|
'id': uuid.uuid4().hex[:12],
|
|
'time': time.time(),
|
|
'source': source,
|
|
'action': action,
|
|
'target': _short(target or ''),
|
|
}
|
|
if detail:
|
|
entry['detail'] = detail
|
|
with _lock:
|
|
entries = _load()
|
|
entries.insert(0, entry)
|
|
_trim(entries)
|
|
atomic_write_json(log_path(), entries)
|
|
return entry
|
|
|
|
|
|
def list_entries(limit=100, source=None):
|
|
with _lock:
|
|
entries = _load()
|
|
picked = [entry for entry in entries if not source or entry.get('source') == source]
|
|
return picked[:limit]
|
|
|
|
|
|
def clear():
|
|
global _entries
|
|
with _lock:
|
|
_entries = []
|
|
atomic_write_json(log_path(), [])
|
|
|
|
|
|
def _target_of(body):
|
|
if not isinstance(body, dict):
|
|
return ''
|
|
for field in TARGET_FIELDS:
|
|
value = body.get(field)
|
|
if isinstance(value, str) and value.strip():
|
|
return os.path.basename(value.strip().rstrip('/\\')) or value.strip()
|
|
return ''
|
|
|
|
|
|
def _failed(response):
|
|
"""True when a response reports failure, including the 200 + {"status": "error"} style."""
|
|
if response.status >= 300:
|
|
return True
|
|
if getattr(response, 'content_type', '') != 'application/json' or not getattr(response, 'body', None):
|
|
return False
|
|
try:
|
|
data = json.loads(response.body)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return isinstance(data, dict) and (data.get('success') is False or data.get('ok') is False
|
|
or data.get('status') in ('error', 'failed'))
|
|
|
|
|
|
async def _request_body(request):
|
|
"""The body the handler already read (aiohttp caches it); {} when it was not JSON or a form."""
|
|
try:
|
|
if request.content_type == 'application/json':
|
|
return await request.json()
|
|
if request.content_type in ('multipart/form-data', 'application/x-www-form-urlencoded'):
|
|
return dict(await request.post())
|
|
except (ValueError, UnicodeDecodeError, RuntimeError):
|
|
pass
|
|
return {}
|
|
|
|
|
|
@web.middleware
|
|
async def activity_middleware(request, handler):
|
|
response = await handler(request)
|
|
action = ROUTE_ACTIONS.get(request.path) if request.method == 'POST' else None
|
|
if action and not _failed(response):
|
|
try:
|
|
target = _target_of(await _request_body(request))
|
|
await asyncio.get_running_loop().run_in_executor(None, add_entry, 'file', action, target)
|
|
except Exception: # noqa: BLE001 - the log must never fail the action it records
|
|
logging.getLogger(__name__).warning('Anomalous activity log: could not record %s', request.path, exc_info=True)
|
|
return response
|
|
|
|
|
|
def _clean_changes(changes):
|
|
cleaned = []
|
|
for change in changes[:MAX_CHANGES]:
|
|
if not isinstance(change, dict):
|
|
continue
|
|
item = {key: _short(change[key]) for key in ('kind', 'node', 'type', 'widget', 'before', 'after')
|
|
if key in change and change[key] is not None}
|
|
if item.get('kind') in ('changed', 'added', 'removed', 'opened'):
|
|
cleaned.append(item)
|
|
return cleaned
|
|
|
|
|
|
async def api_get_activity(request):
|
|
try:
|
|
limit = max(1, min(MAX_ENTRIES, int(request.query.get('limit', '100'))))
|
|
except ValueError:
|
|
limit = 100
|
|
source = request.query.get('source') or None
|
|
entries = await asyncio.get_running_loop().run_in_executor(None, list_entries, limit, source)
|
|
return web.json_response({'entries': entries})
|
|
|
|
|
|
async def api_post_activity(request):
|
|
"""Canvas changes from the browser: {changes: [...], total, workflow}."""
|
|
try:
|
|
body = await request.json()
|
|
except ValueError:
|
|
return web.json_response({'error': 'Invalid JSON body'}, status=400)
|
|
if not isinstance(body, dict) or not isinstance(body.get('changes'), list):
|
|
return web.json_response({'error': 'changes must be a list'}, status=400)
|
|
changes = _clean_changes(body['changes'])
|
|
if not changes:
|
|
return web.json_response({'error': 'No recognizable changes'}, status=400)
|
|
total = body.get('total') if isinstance(body.get('total'), int) else len(changes)
|
|
detail = {'changes': changes, 'total': max(total, len(changes))}
|
|
entry = await asyncio.get_running_loop().run_in_executor(
|
|
None, add_entry, 'canvas', 'canvas', body.get('workflow') if isinstance(body.get('workflow'), str) else '', detail)
|
|
return web.json_response({'entry': entry})
|
|
|
|
|
|
async def api_clear_activity(request):
|
|
await asyncio.get_running_loop().run_in_executor(None, clear)
|
|
return web.json_response({'ok': True})
|
|
|
|
|
|
def register_routes(app):
|
|
if hasattr(app, 'middlewares') and activity_middleware not in app.middlewares:
|
|
app.middlewares.append(activity_middleware)
|
|
app.router.add_get('/anomalous/activity', api_get_activity)
|
|
app.router.add_post('/anomalous/activity', api_post_activity)
|
|
app.router.add_post('/anomalous/activity/clear', api_clear_activity)
|