"""Storage inventory and saved message export analysis.""" import argparse import collections import json from pathlib import Path from .common import BASE, PROFILE, normalized def messages(path): with path.open(encoding='utf-8-sig') as stream: if path.suffix.lower() == '.jsonl': for number, line in enumerate(stream, 1): if not line.strip(): continue try: row = json.loads(line) except json.JSONDecodeError as ex: raise ValueError(f'Invalid JSON at line {number}: {ex}') from ex if not isinstance(row, dict): raise ValueError(f'Line {number} is not a message object') yield row else: data = json.load(stream) if isinstance(data, list): rows = data elif isinstance(data, dict): rows = next((data[key] for key in ('messages', 'models', 'results') if isinstance(data.get(key), list)), None) if rows is None: raise ValueError('Expected messages, models or results array') else: raise ValueError('Expected an array or a message report') for row in rows: if not isinstance(row, dict): raise ValueError('Message array contains a non-object value') yield row def content(row): return '\n'.join(row[key] for key in ('indexed_text', 'body', 'caption', 'text') if isinstance(row.get(key), str)) def inventory(profile): entries = [] for pattern in ('IndexedDB/*whatsapp*', 'Local Storage/leveldb', 'Session Storage'): for folder in profile.glob(pattern): files = [] for file in folder.iterdir() if folder.is_dir() else [folder]: try: if file.is_file(): files.append({'name': file.name, 'bytes': file.stat().st_size}) except OSError: continue entries.append({'path': str(folder), 'files': files}) return {'profile': str(profile), 'exists': profile.exists(), 'storage_inventory': entries, 'coverage': 'File names and sizes only; no decrypted messages.'} def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest='command', required=True) inv = sub.add_parser('inventory', help='List local Desktop storage files and sizes') inv.add_argument('--profile', type=Path, default=PROFILE) for name in ('stats', 'search'): cmd = sub.add_parser(name, help='Analyze an existing JSON/JSONL message export') cmd.add_argument('input', type=Path) cmd.add_argument('--output', type=Path) if name == 'search': cmd.add_argument('query') cmd.add_argument('--chat-id') cmd.add_argument('--limit', type=int, default=100) cmd.add_argument('--redact-text', action='store_true') sub.add_parser('webview-test', help='Harmless JavaScript test in a separate local host') live = sub.add_parser('whatsapp-test', help='Run eight fixed arithmetic checks in the running Desktop instance') live.add_argument('--pid', type=int, help='Optional override; running Desktop host is detected automatically') live.add_argument('--output', type=Path, default=BASE / 'analysis/webview2/live-arithmetic-result.json') search = sub.add_parser('whatsapp-search', help='Use the running app native message search through WebView2') search.add_argument('query') search.add_argument('--pid', type=int, help='Optional override; running Desktop host is detected automatically') search.add_argument('--limit', type=int, default=50) search.add_argument('--output', type=Path, default=BASE / 'analysis/webview2/live-message-search.json') args = parser.parse_args(argv) if args.command == 'whatsapp-test': from .live import main as run_live return run_live(['--suite', 'math'] + (['--pid', str(args.pid)] if args.pid else []) + ['--output', str(args.output)]) if args.command == 'whatsapp-search': if not args.query.strip() or len(args.query)>200 or not 1<=args.limit<=50: parser.error('Search requires 1..200 characters and --limit 1..50') from .native_search import run_live_probe return run_live_probe(args.pid, args.output, args.query, args.limit) if args.command == 'webview-test': from .webview import run_webview_test return run_webview_test() if args.command == 'inventory': result = inventory(args.profile) elif args.command == 'stats': count = with_text = 0 kinds = collections.Counter() chats = set() for row in messages(args.input): count += 1 with_text += bool(content(row)) kinds[str(row.get('type', 'unknown'))] += 1 chat = row.get('chatId', row.get('chat_id')) if chat is not None: chats.add(str(chat)) result = {'source': str(args.input), 'messages': count, 'messages_with_text': with_text, 'chats': len(chats), 'types': dict(kinds), 'scope': 'Saved export only'} else: if not args.query.strip(): parser.error('query must not be empty') if args.limit < 1: parser.error('--limit must be positive') found = [] total = 0 for row in messages(args.input): chat = row.get('chatId', row.get('chat_id')) if args.chat_id is not None and str(chat) != args.chat_id: continue text = content(row) if normalized(args.query) not in normalized(text): continue total += 1 if len(found) >= args.limit: continue item = dict(row) if args.redact_text: # Allowlist metadata so nested fields cannot leak message content. item = {key: value for key, value in row.items() if key in ('id', 'chatId', 'chat_id', 'timestamp', 'type', 'nativeRowID', 'native_rowid') and isinstance(value, (str, int, float, bool, type(None)))} item['text_length'] = len(text) found.append(item) result = {'source': str(args.input), 'matches': total, 'returned': len(found), 'results': found, 'scope': 'Literal search of saved export only'} serialized = json.dumps(result, ensure_ascii=False, indent=2) output = getattr(args, 'output', None) if output: if output.resolve() == args.input.resolve(): parser.error('output must differ from input') output.write_text(serialized + '\n', encoding='utf-8') print(json.dumps({'output': str(output), 'command': args.command})) else: print(serialized) return 0