add user index
This commit is contained in:
+318
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""User directory for Mattermost export search (resolve display names to usernames)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
|
||||
def _norm(s: Optional[str]) -> str:
|
||||
return (s or "").strip()
|
||||
|
||||
|
||||
def _full_name(first_name: str, last_name: str) -> str:
|
||||
parts = [_norm(first_name), _norm(last_name)]
|
||||
return " ".join(p for p in parts if p)
|
||||
|
||||
|
||||
def ensure_users_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT PRIMARY KEY,
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
full_name TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_users_full_name ON users(full_name)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_users_first_name ON users(first_name)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_users_last_name ON users(last_name)")
|
||||
|
||||
|
||||
def profile_from_api_user(user: Dict[str, Any]) -> Dict[str, str]:
|
||||
first_name = _norm(user.get("first_name"))
|
||||
last_name = _norm(user.get("last_name"))
|
||||
nickname = _norm(user.get("nickname"))
|
||||
username = _norm(user.get("username"))
|
||||
return {
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"nickname": nickname,
|
||||
"full_name": _full_name(first_name, last_name),
|
||||
}
|
||||
|
||||
|
||||
def load_users_json(path: str) -> List[Dict[str, str]]:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(f"users.json must be a JSON array: {path}")
|
||||
out: List[Dict[str, str]] = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
username = _norm(item.get("username"))
|
||||
if not username:
|
||||
continue
|
||||
first_name = _norm(item.get("first_name"))
|
||||
last_name = _norm(item.get("last_name"))
|
||||
nickname = _norm(item.get("nickname"))
|
||||
out.append(
|
||||
{
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"nickname": nickname,
|
||||
"full_name": _full_name(first_name, last_name),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def find_users_json(root: str) -> Optional[str]:
|
||||
direct = os.path.join(root, "users.json")
|
||||
if os.path.isfile(direct):
|
||||
return direct
|
||||
return None
|
||||
|
||||
|
||||
def fetch_users_from_api(config_path: str) -> List[Dict[str, str]]:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
host = _norm(config.get("host"))
|
||||
token = _norm(config.get("token"))
|
||||
if not host or not token:
|
||||
raise ValueError(f"config must contain host and token: {config_path}")
|
||||
|
||||
from mattermostdriver import Driver
|
||||
|
||||
d = Driver(
|
||||
{
|
||||
"url": host,
|
||||
"token": token,
|
||||
"scheme": "https",
|
||||
"port": 443,
|
||||
"verify": True,
|
||||
}
|
||||
)
|
||||
d.login()
|
||||
|
||||
profiles: List[Dict[str, str]] = []
|
||||
page = 0
|
||||
while True:
|
||||
users_resp = d.users.get_users(params={"per_page": 200, "page": page})
|
||||
if not users_resp:
|
||||
break
|
||||
for user in users_resp:
|
||||
profile = profile_from_api_user(user)
|
||||
if profile["username"]:
|
||||
profiles.append(profile)
|
||||
page += 1
|
||||
return profiles
|
||||
|
||||
|
||||
def usernames_from_posts(conn: sqlite3.Connection) -> List[str]:
|
||||
rows = conn.execute("SELECT DISTINCT username FROM posts WHERE username != '' ORDER BY username").fetchall()
|
||||
return [str(row[0]) for row in rows]
|
||||
|
||||
|
||||
def upsert_users(conn: sqlite3.Connection, profiles: Iterable[Dict[str, str]]) -> int:
|
||||
ensure_users_schema(conn)
|
||||
now = dt.datetime.now(dt.timezone.utc).isoformat()
|
||||
count = 0
|
||||
for profile in profiles:
|
||||
username = _norm(profile.get("username"))
|
||||
if not username:
|
||||
continue
|
||||
first_name = _norm(profile.get("first_name"))
|
||||
last_name = _norm(profile.get("last_name"))
|
||||
nickname = _norm(profile.get("nickname"))
|
||||
full_name = _norm(profile.get("full_name")) or _full_name(first_name, last_name)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO users (username, first_name, last_name, nickname, full_name, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
first_name = excluded.first_name,
|
||||
last_name = excluded.last_name,
|
||||
nickname = excluded.nickname,
|
||||
full_name = excluded.full_name,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(username, first_name, last_name, nickname, full_name, now),
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def refresh_user_directory(
|
||||
db_path: str,
|
||||
root: str,
|
||||
*,
|
||||
config_path: Optional[str] = None,
|
||||
from_api: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
conn = sqlite3.connect(db_path)
|
||||
ensure_users_schema(conn)
|
||||
sources: List[str] = []
|
||||
profiles: List[Dict[str, str]] = []
|
||||
|
||||
users_json = find_users_json(root)
|
||||
if users_json:
|
||||
profiles.extend(load_users_json(users_json))
|
||||
sources.append(f"users.json:{users_json}")
|
||||
|
||||
if from_api:
|
||||
cfg = config_path or os.environ.get("MM_CONFIG_PATH", "config.json")
|
||||
if not os.path.isfile(cfg):
|
||||
raise FileNotFoundError(f"Mattermost config not found: {cfg}")
|
||||
profiles.extend(fetch_users_from_api(cfg))
|
||||
sources.append(f"api:{cfg}")
|
||||
|
||||
with conn:
|
||||
upserted_named = upsert_users(conn, profiles) if profiles else 0
|
||||
post_only = 0
|
||||
known = {p["username"] for p in profiles}
|
||||
for username in usernames_from_posts(conn):
|
||||
if username in known:
|
||||
continue
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO users (username, first_name, last_name, nickname, full_name, updated_at)
|
||||
VALUES (?, '', '', '', '', ?)
|
||||
ON CONFLICT(username) DO NOTHING
|
||||
""",
|
||||
(username, dt.datetime.now(dt.timezone.utc).isoformat()),
|
||||
)
|
||||
post_only += 1
|
||||
sources.append("posts:distinct_usernames")
|
||||
|
||||
total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
conn.close()
|
||||
return {
|
||||
"db": db_path,
|
||||
"root": root,
|
||||
"sources": sources,
|
||||
"upserted_profiles": upserted_named,
|
||||
"added_from_posts": post_only,
|
||||
"total_users": total,
|
||||
}
|
||||
|
||||
|
||||
def _user_row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
|
||||
first_name = row["first_name"] or ""
|
||||
last_name = row["last_name"] or ""
|
||||
nickname = row["nickname"] or ""
|
||||
full_name = row["full_name"] or _full_name(first_name, last_name)
|
||||
display = full_name or nickname or row["username"]
|
||||
return {
|
||||
"username": row["username"],
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"nickname": nickname,
|
||||
"name": display,
|
||||
"full_name": full_name,
|
||||
}
|
||||
|
||||
|
||||
def _match_score(ref: str, row: sqlite3.Row) -> int:
|
||||
ref_l = ref.casefold()
|
||||
username = row["username"] or ""
|
||||
first_name = row["first_name"] or ""
|
||||
last_name = row["last_name"] or ""
|
||||
nickname = row["nickname"] or ""
|
||||
full_name = row["full_name"] or _full_name(first_name, last_name)
|
||||
|
||||
if username.casefold() == ref_l:
|
||||
return 100
|
||||
for field in (first_name, last_name, nickname, full_name):
|
||||
if field and field.casefold() == ref_l:
|
||||
return 90
|
||||
for field in (first_name, last_name, nickname, full_name, username):
|
||||
if field and ref_l in field.casefold():
|
||||
return 70
|
||||
return 0
|
||||
|
||||
|
||||
def _field_contains(field: str, ref: str) -> bool:
|
||||
return ref.casefold() in (field or "").casefold()
|
||||
|
||||
|
||||
def search_users(
|
||||
db_path: str,
|
||||
query: str,
|
||||
*,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
ref = _norm(query)
|
||||
if not ref:
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
ensure_users_schema(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT username, first_name, last_name, nickname, full_name FROM users"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
matched = [
|
||||
row
|
||||
for row in rows
|
||||
if any(
|
||||
_field_contains(field, ref)
|
||||
for field in (
|
||||
row["username"],
|
||||
row["first_name"],
|
||||
row["last_name"],
|
||||
row["nickname"],
|
||||
row["full_name"],
|
||||
)
|
||||
)
|
||||
]
|
||||
scored = [(_match_score(ref, row), _user_row_to_dict(row)) for row in matched]
|
||||
scored.sort(key=lambda item: (-item[0], item[1]["username"]))
|
||||
return [item[1] for item in scored[:limit]]
|
||||
|
||||
|
||||
def resolve_user(db_path: str, ref: str) -> Dict[str, Any]:
|
||||
ref = _norm(ref)
|
||||
if not ref:
|
||||
raise ValueError("ref must be non-empty")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
ensure_users_schema(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT username, first_name, last_name, nickname, full_name FROM users"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
best = [row for row in rows if _match_score(ref, row) > 0]
|
||||
best.sort(key=lambda row: (-_match_score(ref, row), row["username"]))
|
||||
|
||||
if not best:
|
||||
return {"found": False, "ref": ref, "matches": []}
|
||||
|
||||
top = _match_score(ref, best[0])
|
||||
top_matches = [row for row in best if _match_score(ref, row) == top]
|
||||
result_matches = [_user_row_to_dict(row) for row in top_matches[:10]]
|
||||
|
||||
if len(result_matches) == 1:
|
||||
user = result_matches[0]
|
||||
return {"found": True, "ref": ref, "username": user["username"], **user}
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"ambiguous": True,
|
||||
"ref": ref,
|
||||
"matches": result_matches,
|
||||
}
|
||||
Reference in New Issue
Block a user