Files
mattermost-dl/mm_search.py
T
Сергей Маринкевич b83d4f8b9b add user index
2026-06-05 17:19:12 +07:00

533 lines
17 KiB
Python

#!/usr/bin/env python3
import argparse
import datetime as dt
import json
import os
import re
import sqlite3
from typing import Any, Dict, Iterable, List, Optional
import mm_users
def iter_result_files(root: str) -> Iterable[str]:
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
if not name.endswith(".json"):
continue
yield os.path.join(dirpath, name)
def parse_iso_date(s: str) -> dt.datetime:
# Accept YYYY-MM-DD or full ISO with time; normalize to UTC
try:
if "T" in s:
return dt.datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(dt.timezone.utc)
return dt.datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=dt.timezone.utc)
except ValueError as e:
raise argparse.ArgumentTypeError(f"invalid date '{s}': {e}") from e
def parse_post_created(s: str) -> dt.datetime:
# Exporter uses '%Y-%m-%dT%H:%M:%SZ'
return dt.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.timezone.utc)
def make_matcher(pattern: str, use_regex: bool, ignore_case: bool):
if use_regex:
flags = re.IGNORECASE if ignore_case else 0
rx = re.compile(pattern, flags)
def match(text: str) -> bool:
return bool(rx.search(text))
return match
needle = pattern.lower() if ignore_case else pattern
def match(text: str) -> bool:
hay = text.lower() if ignore_case else text
return needle in hay
return match
def build_fts_query(query: str) -> Optional[str]:
# Tokenize user query and run prefix search by tokens.
tokens = re.findall(r"\w+", query, flags=re.UNICODE)
if not tokens:
return None
return " AND ".join(f"{tok}*" for tok in tokens)
def search_index(
db_path: str,
query: str,
user: Optional[str],
channel: Optional[str],
team: Optional[str],
after: Optional[dt.datetime],
before: Optional[dt.datetime],
limit: Optional[int],
ignore_case: bool,
) -> Iterable[Dict[str, Any]]:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
filters = []
params: List[Any] = []
if user:
filters.append("p.username = ?")
params.append(user)
if channel:
filters.append("(p.channel_display_name = ? OR p.channel_name = ?)")
params.extend([channel, channel])
if team:
filters.append("p.team = ?")
params.append(team)
if after:
filters.append("p.created >= ?")
params.append(after.isoformat())
if before:
filters.append("p.created <= ?")
params.append(before.isoformat())
where_filters = f"AND {' AND '.join(filters)}" if filters else ""
fts_q = build_fts_query(query)
if fts_q:
sql = f"""
SELECT
p.post_id, p.idx, p.created, p.username, p.message, p.files_json,
p.channel_display_name, p.channel_name, p.team, p.source_path
FROM posts p
JOIN posts_fts fts ON fts.post_id = p.post_id
WHERE fts.message MATCH ?
{where_filters}
ORDER BY p.created DESC
"""
params = [fts_q] + params
else:
cmp_col = "LOWER(p.message)" if ignore_case else "p.message"
needle = query.lower() if ignore_case else query
sql = f"""
SELECT
p.post_id, p.idx, p.created, p.username, p.message, p.files_json,
p.channel_display_name, p.channel_name, p.team, p.source_path
FROM posts p
WHERE {cmp_col} LIKE ?
{where_filters}
ORDER BY p.created DESC
"""
params = [f"%{needle}%"] + params
if limit is not None:
sql += " LIMIT ?"
params.append(limit)
rows = conn.execute(sql, params).fetchall()
for row in rows:
files: Optional[List[str]] = None
if row["files_json"]:
try:
maybe_files = json.loads(row["files_json"])
if isinstance(maybe_files, list):
files = maybe_files
except json.JSONDecodeError:
files = None
post: Dict[str, Any] = {
"id": row["post_id"],
"idx": row["idx"],
"created": row["created"].replace("+00:00", "Z"),
"username": row["username"],
"message": row["message"],
}
if files:
post["files"] = files
yield {
"file": row["source_path"],
"channel": row["channel_display_name"] or row["channel_name"],
"team": row["team"],
"post": post,
}
finally:
conn.close()
def search_attachments_index(
db_path: str,
query: str,
user: Optional[str],
channel: Optional[str],
team: Optional[str],
after: Optional[dt.datetime],
before: Optional[dt.datetime],
limit: Optional[int],
mode: str,
) -> Iterable[Dict[str, Any]]:
"""
Search in attachments index.
mode: "name" to search by file name, "content" to search by text content.
"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
filters = []
params: List[Any] = []
if user:
filters.append("p.username = ?")
params.append(user)
if channel:
filters.append("(p.channel_display_name = ? OR p.channel_name = ?)")
params.extend([channel, channel])
if team:
filters.append("p.team = ?")
params.append(team)
if after:
filters.append("p.created >= ?")
params.append(after.isoformat())
if before:
filters.append("p.created <= ?")
params.append(before.isoformat())
where_filters = f"AND {' AND '.join(filters)}" if filters else ""
fts_column = "filename" if mode == "name" else "content"
fts_q = build_fts_query(query)
if not fts_q:
return []
sql = f"""
SELECT
p.post_id,
p.idx,
p.created,
p.username,
p.message,
p.files_json,
p.channel_display_name,
p.channel_name,
p.team,
p.source_path,
a.filename AS attachment_filename,
a.filepath AS attachment_path,
a.is_text AS attachment_is_text
FROM attachments a
JOIN attachments_fts aft ON aft.attachment_id = a.id
JOIN posts p ON p.post_id = a.post_id
WHERE aft.{fts_column} MATCH ?
{where_filters}
ORDER BY p.created DESC
"""
params = [fts_q] + params
if limit is not None:
sql += " LIMIT ?"
params.append(limit)
rows = conn.execute(sql, params).fetchall()
for row in rows:
files: Optional[List[str]] = None
if row["files_json"]:
try:
maybe_files = json.loads(row["files_json"])
if isinstance(maybe_files, list):
files = maybe_files
except json.JSONDecodeError:
files = None
post: Dict[str, Any] = {
"id": row["post_id"],
"idx": row["idx"],
"created": row["created"].replace("+00:00", "Z"),
"username": row["username"],
"message": row["message"],
}
if files:
post["files"] = files
attachment = {
"filename": row["attachment_filename"],
"path": row["attachment_path"],
"is_text": bool(row["attachment_is_text"]),
}
yield {
"file": row["source_path"],
"channel": row["channel_display_name"] or row["channel_name"],
"team": row["team"],
"post": post,
"attachment": attachment,
}
finally:
conn.close()
def search_file(
path: str,
matcher,
user: Optional[str],
channel: Optional[str],
team: Optional[str],
after: Optional[dt.datetime],
before: Optional[dt.datetime],
) -> Iterable[Dict[str, Any]]:
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return []
ch = data.get("channel", {}) or {}
posts: List[Dict[str, Any]] = data.get("posts") or []
channel_name = ch.get("display_name") or ch.get("name") or ""
team_name = ch.get("team") or ""
if channel and channel not in (channel_name, ch.get("name", "")):
return []
if team and team != team_name:
return []
for p in posts:
msg = p.get("message") or ""
username = p.get("username") or ""
created_raw = p.get("created")
if not isinstance(created_raw, str):
continue
try:
created = parse_post_created(created_raw)
except ValueError:
continue
if user and username != user:
continue
if after and created < after:
continue
if before and created > before:
continue
if not matcher(msg):
continue
yield {
"file": path,
"channel": channel_name,
"team": team_name,
"post": p,
}
def print_human(matches: Iterable[Dict[str, Any]], limit: Optional[int], show_file: bool) -> None:
count = 0
for m in matches:
if limit is not None and count >= limit:
break
p = m["post"]
created = p.get("created", "")
username = p.get("username", "")
msg = (p.get("message") or "").replace("\n", "\\n")
attachment = m.get("attachment")
attachment_suffix = ""
if isinstance(attachment, dict) and attachment.get("filename"):
attachment_suffix = f" [file: {attachment['filename']}]"
if show_file:
file_rel = os.path.relpath(m["file"])
idx = p.get("idx", "?")
prefix = f"{file_rel}:{idx}: "
else:
prefix = ""
channel = m.get("channel") or "?"
team = m.get("team") or ""
team_prefix = f"[{team}]" if team else ""
print(f"{prefix}{team_prefix}[{channel}] {created} {username}: {msg}{attachment_suffix}")
count += 1
def print_json(matches: Iterable[Dict[str, Any]], limit: Optional[int]) -> None:
out: List[Dict[str, Any]] = []
count = 0
for m in matches:
if limit is not None and count >= limit:
break
out.append(m)
count += 1
json.dump(out, fp=os.sys.stdout, ensure_ascii=False, indent=2)
print()
def main(argv: Optional[List[str]] = None) -> int:
ap = argparse.ArgumentParser(
description="Search through Mattermost export JSON files produced by mattermost-dl.py.",
)
ap.add_argument("query", nargs="?", help="Search string or regex (see --regex).")
ap.add_argument(
"-r",
"--root",
default="results",
help="Root directory with export results (default: results).",
)
ap.add_argument("--user", help="Filter by username (exact match).")
ap.add_argument("--channel", help="Filter by channel display_name or name (exact match).")
ap.add_argument("--team", help="Filter by team name (exact match).")
ap.add_argument("--after", type=parse_iso_date, help="Only messages on/after this date (YYYY-MM-DD or ISO).")
ap.add_argument("--before", type=parse_iso_date, help="Only messages on/before this date (YYYY-MM-DD or ISO).")
ap.add_argument("--regex", action="store_true", help="Treat query as regular expression.")
group = ap.add_mutually_exclusive_group()
group.add_argument(
"--file-name",
action="store_true",
help="Search by attachment file name instead of message text (requires sqlite index).",
)
group.add_argument(
"--file-content",
action="store_true",
help="Search by attachment text content instead of message text (requires sqlite index; only UTF-8 text files are indexed).",
)
ap.add_argument(
"-i",
"--ignore-case",
action="store_true",
help="Case-insensitive search (default for substring search).",
)
ap.add_argument("--json", action="store_true", help="Output matches as JSON array (for jq, etc.).")
ap.add_argument(
"-n",
"--limit",
type=int,
help="Maximum number of matches to output.",
)
ap.add_argument("--db", default="mattermost_index.sqlite", help="SQLite index database path.")
ap.add_argument(
"--no-index",
action="store_true",
help="Do not use sqlite index even if DB exists.",
)
ap.add_argument(
"--show-file",
action="store_true",
help="Include source filename and per-file index in human output.",
)
user_group = ap.add_mutually_exclusive_group()
user_group.add_argument(
"--resolve-user",
metavar="REF",
help="Resolve display name / FIO / nickname to username (uses sqlite user directory).",
)
user_group.add_argument(
"--search-users",
metavar="QUERY",
help="Search users by name or username substring.",
)
ap.add_argument(
"--refresh-users",
action="store_true",
help="Refresh user directory from users.json, optional API, and post usernames.",
)
ap.add_argument(
"--from-api",
action="store_true",
help="With --refresh-users, also fetch profiles from Mattermost API (config.json).",
)
ap.add_argument(
"--config",
default=os.environ.get("MM_CONFIG_PATH", "config.json"),
help="Mattermost config path for --from-api (default: MM_CONFIG_PATH or config.json).",
)
args = ap.parse_args(argv)
if args.refresh_users:
result = mm_users.refresh_user_directory(
args.db,
args.root,
config_path=args.config,
from_api=args.from_api,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if args.resolve_user:
result = mm_users.resolve_user(args.db, args.resolve_user)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if args.search_users:
matches = mm_users.search_users(args.db, args.search_users, limit=args.limit or 10)
print(json.dumps(matches, ensure_ascii=False, indent=2))
return 0
if not args.query:
ap.error("query is required unless using --resolve-user, --search-users, or --refresh-users")
# Default ignore-case for non-regex search unless explicitly disabled
ignore_case = args.ignore_case or not args.regex
matcher = make_matcher(args.query, args.regex, ignore_case)
if (args.file_name or args.file_content) and args.regex:
raise SystemExit("--file-name/--file-content do not support --regex yet.")
use_index = (not args.no_index) and os.path.exists(args.db)
if args.file_name or args.file_content:
if not use_index:
raise SystemExit(
"Attachment search (--file-name/--file-content) requires sqlite index. "
"Create it with mm_index.py and do not use --no-index."
)
mode = "name" if args.file_name else "content"
matches_iter = search_attachments_index(
db_path=args.db,
query=args.query,
user=args.user,
channel=args.channel,
team=args.team,
after=args.after,
before=args.before,
limit=args.limit,
mode=mode,
)
output_limit = None
elif use_index and not args.regex:
matches_iter = search_index(
db_path=args.db,
query=args.query,
user=args.user,
channel=args.channel,
team=args.team,
after=args.after,
before=args.before,
limit=args.limit,
ignore_case=ignore_case,
)
# Limit already applied in SQL.
output_limit = None
else:
files = list(iter_result_files(args.root))
matches_iter = (
m
for path in files
for m in search_file(
path=path,
matcher=matcher,
user=args.user,
channel=args.channel,
team=args.team,
after=args.after,
before=args.before,
)
)
output_limit = args.limit
if args.json:
print_json(matches_iter, output_limit)
else:
print_human(matches_iter, output_limit, show_file=args.show_file)
return 0
if __name__ == "__main__":
raise SystemExit(main())