[Added filter by contact and date for messages Adrian Georgescu **20260719085351 Ignore-this: 9e4582c0ee8fa0adb4281dd772050a456d4705c2c007261e5d8688f3b048eac16e3572796abca790 ] hunk ./sylk/applications/webrtcgateway/web.py 762 + hunk ./sylk/applications/webrtcgateway/web.py 767 + + hunk ./sylk/applications/webrtcgateway/web.py 1121 +let msgFilters = { account: '', contact: '', date: '' }; +const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + hunk ./sylk/applications/webrtcgateway/web.py 1128 - $('msgTypesResult').innerHTML = `
Loading message types for ${esc(account)}…
`; - const r = await api('messages/types/' + encodeURIComponent(account)); - if (r.status === 403) { boot(); return false; } - const j = await r.json().catch(() => null); + msgFilters = { account: account, contact: '', date: '' }; + const sel = $('msgContactSelect'); + if (sel) sel.innerHTML = ''; + if ($('msgContactRow')) $('msgContactRow').style.display = 'none'; + if ($('msgDateRow')) $('msgDateRow').style.display = 'none'; + msgFetch(); + return false; +} + +function msgSetContact(contact) { + msgFilters.contact = contact; + msgFetch(); +} + +function msgSetDate(date) { + msgFilters.date = date; + msgFetch(); +} + +function msgDateLabel(date) { + if (!date) return ''; + const year = date.slice(0, 4), month = date.slice(5, 7), day = date.slice(8, 10); + if (day) return `${+day} ${MONTH_NAMES[+month - 1]} ${year}`; + if (month) return `${MONTH_NAMES[+month - 1]} ${year}`; + return year; +} + +// Year -> Month -> Day drill-down with per-level counters, like the +// client-side messages date filter. +function msgRenderDates(j) { + const row = $('msgDateRow'); + if (!row) return; + const date = j.date || ''; + const counts = j.date_counts || {}; + const year = date.slice(0, 4), month = date.slice(5, 7), day = date.slice(8, 10); + const crumbs = []; + crumbs.push(date ? `All dates` : 'All dates'); + if (year) crumbs.push(month ? `${year}` : `${year}`); + if (month) crumbs.push(day ? `${MONTH_NAMES[+month - 1]}` : `${MONTH_NAMES[+month - 1]}`); + if (day) crumbs.push(`${+day}`); + let keys = Object.keys(counts).sort(); + if (!year) keys.reverse(); // most recent year first; months and days in calendar order + const chips = keys.map(k => { + const next = !year ? k : !month ? `${year}-${k}` : `${year}-${month}-${k}`; + const label = !year ? k : !month ? MONTH_NAMES[+k - 1] : +k; + return ``; + }).join(''); + row.style.display = 'flex'; + row.innerHTML = ` + + ${crumbs.join(' › ')} +
${chips || 'no messages at this level'}
`; +} + +async function msgFetch() { + const account = msgFilters.account; hunk ./sylk/applications/webrtcgateway/web.py 1185 - if (!j) { el.innerHTML = '
Lookup failed.
'; return false; } - if (j.error) { el.innerHTML = `
${esc(j.error)}
`; return false; } + if (!el || !account) return; + el.innerHTML = `
Loading message types for ${esc(account)}${msgFilters.contact ? ' with contact ' + esc(msgFilters.contact) : ''}…
`; + const params = []; + if (msgFilters.contact) params.push('contact=' + encodeURIComponent(msgFilters.contact)); + if (msgFilters.date) params.push('date=' + encodeURIComponent(msgFilters.date)); + const r = await api('messages/types/' + encodeURIComponent(account) + (params.length ? '?' + params.join('&') : '')); + if (r.status === 403) { boot(); return; } + const j = await r.json().catch(() => null); + if (!j) { el.innerHTML = '
Lookup failed.
'; return; } + if (j.error) { el.innerHTML = `
${esc(j.error)}
`; return; } + const contacts = j.contacts || []; + const contactRow = $('msgContactRow'), dateRow = $('msgDateRow'); + if (!contacts.length) { + if (contactRow) contactRow.style.display = 'none'; + if (dateRow) dateRow.style.display = 'none'; + el.innerHTML = `
No messages stored for ${esc(account)}.
`; + return; + } + // account found — show the filters and fill the contact options + const sel = $('msgContactSelect'); + if (sel) { + sel.innerHTML = [''] + .concat(contacts.map(c => ``)) + .join(''); + } + if (contactRow) contactRow.style.display = 'flex'; + msgRenderDates(j); hunk ./sylk/applications/webrtcgateway/web.py 1214 - if (!types.length) { - el.innerHTML = `
No messages stored for ${esc(account)}.
`; - return false; - } - const rows = types.map(t => ` + const filtered = j.contact || j.date; + const scope = `${j.contact ? ` with ${esc(j.contact)}` : ''}` + + `${j.date ? ` in ${esc(msgDateLabel(j.date))}` : ''}`; + // Delete rules per content type: text/* only with a full day selected + // (too easy to wipe whole conversations otherwise), file transfers + // never (the File transfers tab handles those, files included), + // anything else always. + const daySelected = (j.date || '').length === 10; + const deleteCell = t => { + if (!j.can_delete) return ''; + if (t.startsWith('application/sylk-file-transfer')) + return 'use the File transfers tab'; + if (t.startsWith('text/') && !daySelected) + return 'select a day to delete'; + return ``; + }; + const rows = types.length ? types.map(t => ` hunk ./sylk/applications/webrtcgateway/web.py 1234 - - ${j.can_delete ? `` : ''} - - `).join(''); + ${deleteCell(t)} + `).join('') + : `No messages match the current filters.`; hunk ./sylk/applications/webrtcgateway/web.py 1239 - ${Number(j.total).toLocaleString()} message(s) for ${esc(account)} + ${Number(j.total).toLocaleString()} message(s) for ${esc(account)}${scope} hunk ./sylk/applications/webrtcgateway/web.py 1246 - ${j.can_delete ? '' : '
deletion is only available on the Cassandra backend
'} + ${j.can_delete ? (filtered ? '
delete removes only the messages matching the current filters
' : '') : '
deletion is only available on the Cassandra backend
'} hunk ./sylk/applications/webrtcgateway/web.py 1248 - return false; hunk ./sylk/applications/webrtcgateway/web.py 1253 - if (!confirm(`Permanently delete ${count} ${type} message(s) for ${account}?\n\nThis cannot be undone.`)) return; + const scope = (msgFilters.contact ? `\nContact: ${msgFilters.contact}` : '') + + (msgFilters.date ? `\nDate: ${msgDateLabel(msgFilters.date)}` : ''); + if (!confirm(`Permanently delete ${count} ${type} message(s) for ${account}?${scope}\n\nThis cannot be undone.`)) return; hunk ./sylk/applications/webrtcgateway/web.py 1263 - body: JSON.stringify({ content_type: type }), + body: JSON.stringify({ content_type: type, contact: msgFilters.contact, + date: msgFilters.date }), hunk ./sylk/applications/webrtcgateway/web.py 1271 - loadMessageTypes(); // refresh the breakdown + msgFetch(); // refresh the breakdown hunk ./sylk/applications/webrtcgateway/web.py 1553 - const sub = d.count ? `${d.count} · ${fmtBytes(d.bytes)}` : '0'; + const sub = d.count ? `(${d.count} · ${fmtBytes(d.bytes)})` : '(0)'; hunk ./sylk/applications/webrtcgateway/web.py 3074 + @staticmethod + def _message_type_stats(messages, get, contact_filter, date_filter): + """Message counts by content type, with the admin drill-down + filters applied: contact narrows to one contact and date_filter + is a drill-down prefix ('', YYYY, YYYY-MM or YYYY-MM-DD) that + narrows to that year, month or day. date_counts holds the next + drill-down level below the current selection — messages per + year, per month of the selected year, or per day of the + selected month — like the client-side date filter. + + The two filters cross-scope each other's counters, so the + drill-down works in one direction at a time: with a contact + selected the date counters cover only that contact (until the + contact is reset), and with a date selected the contact list + and its counters cover only that period (until the date is + reset).""" + by_type = {} + contacts = {} + date_counts = {} + oldest = newest = None + level = len(date_filter) + for message in messages: + contact = (get(message, 'contact') or '').strip().lower() + created = str(get(message, 'created_at') or get(message, 'timestamp') or '') + date_match = not date_filter or created.startswith(date_filter) + if date_match: + # contact counters are scoped by the date filter + contacts[contact] = contacts.get(contact, 0) + 1 + if contact_filter and contact != contact_filter: + continue + # date counters are scoped by the contact filter + if created: + if level == 0: + key = created[:4] + elif created.startswith(date_filter): + key = created[5:7] if level == 4 else created[8:10] if level == 7 else None + else: + key = None + if key: + date_counts[key] = date_counts.get(key, 0) + 1 + if not date_match: + continue + content_type = get(message, 'content_type') or 'unknown' + by_type[content_type] = by_type.get(content_type, 0) + 1 + if created: + oldest = created if oldest is None or created < oldest else oldest + newest = created if newest is None or created > newest else newest + contact_list = [{'name': name, 'count': count} for name, count in contacts.items()] + contact_list.sort(key=lambda entry: (-entry['count'], entry['name'])) + by_type = dict(sorted(by_type.items(), key=lambda item: -item[1])) + return {'by_type': by_type, 'total': sum(by_type.values()), + 'contacts': contact_list, + 'date_counts': dict(sorted(date_counts.items())), + 'oldest': oldest[:19] if oldest else None, # trim milliseconds + 'newest': newest[:19] if newest else None} + + @staticmethod + def _date_prefix_arg(value): + # a date drill-down prefix: YYYY, YYYY-MM or YYYY-MM-DD + value = (value or '').strip()[:10] + patterns = {4: 'dddd', 7: 'dddd-dd', 10: 'dddd-dd-dd'} + pattern = patterns.get(len(value)) + if pattern and all((c == '-') == (p == '-') and (p == '-' or c.isdigit()) + for c, p in zip(value, pattern)): + return value + return '' + hunk ./sylk/applications/webrtcgateway/web.py 3146 + args = request.args or {} + contact_filter = args.get(b'contact', [b''])[0].decode('utf-8', 'replace').strip().lower() + date_filter = self._date_prefix_arg(args.get(b'date', [b''])[0].decode('utf-8', 'replace')) hunk ./sylk/applications/webrtcgateway/web.py 3152 - by_type = {} - oldest = newest = None + messages = [] hunk ./sylk/applications/webrtcgateway/web.py 3157 - for message in json.load(f): - content_type = message.get('content_type') or 'unknown' - by_type[content_type] = by_type.get(content_type, 0) + 1 - created_at = message.get('created_at') or message.get('timestamp') - if created_at: - oldest = created_at if oldest is None or created_at < oldest else oldest - newest = created_at if newest is None or created_at > newest else newest + messages = json.load(f) hunk ./sylk/applications/webrtcgateway/web.py 3160 - by_type = dict(sorted(by_type.items(), key=lambda item: -item[1])) - return json.dumps({'account': account, 'backend': 'file', 'can_delete': False, - 'total': sum(by_type.values()), 'by_type': by_type, - 'oldest': str(oldest) if oldest else None, - 'newest': str(newest) if newest else None}) + stats = self._message_type_stats(messages, lambda m, k: m.get(k), + contact_filter, date_filter) + return json.dumps(dict(stats, account=account, backend='file', can_delete=False, + contact=contact_filter, date=date_filter)) hunk ./sylk/applications/webrtcgateway/web.py 3169 + result = {'account': account, 'backend': 'cassandra', 'can_delete': True, + 'contact': contact_filter, 'date': date_filter} hunk ./sylk/applications/webrtcgateway/web.py 3172 - result = {'account': account, 'backend': 'cassandra', 'can_delete': True} - by_type = {} - oldest = newest = None hunk ./sylk/applications/webrtcgateway/web.py 3173 - for message in ChatMessage.objects(ChatMessage.account == account).limit(None): - content_type = message.content_type or 'unknown' - by_type[content_type] = by_type.get(content_type, 0) + 1 - created_at = message.created_at - if created_at is not None: - oldest = created_at if oldest is None or created_at < oldest else oldest - newest = created_at if newest is None or created_at > newest else newest + messages = ChatMessage.objects(ChatMessage.account == account).limit(None) + result.update(self._message_type_stats(messages, lambda m, k: getattr(m, k, None), + contact_filter, date_filter)) hunk ./sylk/applications/webrtcgateway/web.py 3178 - result['by_type'] = dict(sorted(by_type.items(), key=lambda item: -item[1])) - result['total'] = sum(by_type.values()) - result['oldest'] = str(oldest) if oldest is not None else None - result['newest'] = str(newest) if newest is not None else None hunk ./sylk/applications/webrtcgateway/web.py 3197 + contact_filter = (payload.get('contact') or '').strip().lower() + date_filter = self._date_prefix_arg(payload.get('date')) + # same rules the UI enforces: file transfers are deleted from the + # File transfers tab (files included); text messages only for a + # selected day, to avoid wiping whole conversations by accident + if content_type.startswith(self.FILE_TRANSFER_CONTENT_TYPE): + request.setResponseCode(400) + return json.dumps({'ok': False, 'error': 'file-transfer messages are deleted from the File transfers tab'}) + if content_type.startswith('text/') and len(date_filter) != 10: + request.setResponseCode(400) + return json.dumps({'ok': False, 'error': 'text messages can only be deleted with a day selected'}) hunk ./sylk/applications/webrtcgateway/web.py 3213 - log.info('[admin] delete messages requested for {} with type {}'.format(account, content_type)) + log.info('[admin] delete messages requested for {} with type {}{}{}'.format( + account, content_type, + ' contact {}'.format(contact_filter) if contact_filter else '', + ' date {}'.format(date_filter) if date_filter else '')) hunk ./sylk/applications/webrtcgateway/web.py 3222 - result = {'ok': True, 'account': account, 'content_type': content_type} + result = {'ok': True, 'account': account, 'content_type': content_type, + 'contact': contact_filter, 'date': date_filter} hunk ./sylk/applications/webrtcgateway/web.py 3225 + + def matches(message): + if message.content_type != content_type: + return False + if contact_filter and (message.contact or '').strip().lower() != contact_filter: + return False + if date_filter and not str(message.created_at or '').startswith(date_filter): + return False + return True + hunk ./sylk/applications/webrtcgateway/web.py 3237 - if message.content_type == content_type] + if matches(message)]