commit 1ce803fc9ccf26ac06e8040dccb5b252fb89f1ed Author: ilia Date: Fri May 29 13:53:28 2026 -0400 Initial import: Slack export triage UI for Mattermost migration. Web UI for channel/message cherry-pick, attachment preview, hydrate script, and workspace skip presets. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37bdf18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +manifest.json +.index-cache.json +__pycache__/ +*.pyc +.DS_Store +.venv/ +venv/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2fb28b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: help review hydrate preset-skips + +help: ## Show targets + @grep -E '^[a-zA-Z0-9_-]+:.*##' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +review: ## Start triage UI (http://127.0.0.1:8765) + @chmod +x run.sh + @./run.sh + +hydrate: ## Download attachments from official Slack export URLs + @chmod +x scripts/hydrate-export.sh + @./scripts/hydrate-export.sh + +preset-skips: ## Apply presets/levkin-skips.json channel skips to manifest.json + @python3 scripts/preset-skips.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..6dec16c --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# slack-sieve + +Sieve your [Slack workspace export](https://slack.com/help/articles/201658943-Export-your-workspace-data) before [Mattermost](https://mattermost.com) import — triage channels, cherry-pick messages, preview attachments. + +```text +Slack export → hydrate (attachments) → review UI → manifest.json → (prepare-export) → mmetl → mmctl +``` + +## Quick start + +```bash +git clone git@git.levkin.ca:ilia/slack-sieve.git +cd slack-sieve + +# Optional: download files into __uploads/ from export token URLs +export SLACK_EXPORT_DIR="$HOME/Downloads/your-slack-export" +export SLACK_HYDRATED_DIR="$HOME/Downloads/your-slack-export-enriched" +make hydrate + +# Optional: pre-skip noise channels (edit presets/levkin-skips.json or add your own) +make preset-skips + +# Review at http://127.0.0.1:8765 +export SLACK_EXPORT_PATH="$HOME/Downloads/your-slack-export-enriched" +make review +``` + +Selections are saved to `manifest.json` (gitignored). + +## Make targets + +| Target | Description | +|--------|-------------| +| `make review` | Start the web UI on port 8765 | +| `make hydrate` | Copy export + download attachments via embedded `xoxe` URLs | +| `make preset-skips` | Mark preset channels as skip in `manifest.json` | + +## Environment + +| Variable | Default | +|----------|---------| +| `SLACK_EXPORT_PATH` | `~/Downloads/Levkin Slack export enriched` | +| `SLACK_EXPORT_DIR` | Raw export for hydrate | +| `SLACK_HYDRATED_DIR` | Output folder for hydrate | +| `SLACK_IMPORT_PORT` | `8765` | + +## UI shortcuts + +- **J/K** — previous/next message +- **Space** — toggle cherry-pick +- **I / S** — import/skip message +- **Shift+I / Shift+S** — import/skip whole channel +- **N** — next undecided channel + +## Presets + +Edit `presets/levkin-skips.json` (or copy it) for workspace-specific channels to auto-skip. + +## Mattermost import (after review) + +1. Build a filtered export from `manifest.json` (prepare-export — planned). +2. [mmetl](https://github.com/mattermost/mmetl) `transform slack` +3. [mmctl](https://docs.mattermost.com/administration-guide/manage/mmctl-command-line-tool.html) `import` + +Docs: [Migrate from Slack](https://docs.mattermost.com/administration-guide/onboard/migrate-from-slack.html) + +## Requirements + +- Python 3.10+ (stdlib only for server) +- Official Slack export folder or enriched copy with `channels.json`, `users.json`, channel dirs + +## Remote + +```text +git@git.levkin.ca:ilia/slack-sieve.git +``` + +SSH uses LAN `10.0.10.169` via `Host git.levkin.ca` in `~/.ssh/config` (see homelab ansible docs). diff --git a/presets/levkin-skips.json b/presets/levkin-skips.json new file mode 100644 index 0000000..be05e23 --- /dev/null +++ b/presets/levkin-skips.json @@ -0,0 +1,52 @@ +{ + "tier_a": [ + "jewbottest", + "help", + "baby", + "meetings", + "garden", + "recipes", + "integromat", + "clickup", + "grants", + "general", + "after-school-activities", + "cleaner", + "medical", + "purchase", + "temp" + ], + "tier_b": [ + "viewings", + "moishe", + "520steeles", + "29wilson", + "york", + "jam" + ], + "other": [ + "FC:F08SHU1FYVC:Reading List" + ], + "review_priority": [ + "284richmond", + "45ritson", + "122harmony", + "153niagara", + "6keefer", + "banking", + "realestate", + "airbnb", + "izik", + "zoey", + "levkin" + ], + "review_later": [ + "random", + "professional", + "parenting", + "adhd", + "baking-biz", + "standup", + "zane" + ] +} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..0815138 --- /dev/null +++ b/run.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Start the Slack export review UI. +# Usage: +# ./tools/slack-mattermost-import/run.sh +# SLACK_EXPORT_PATH='/path/to/export' ./tools/slack-mattermost-import/run.sh + +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" + +export SLACK_EXPORT_PATH="${SLACK_EXPORT_PATH:-$HOME/Downloads/Levkin Slack export enriched}" +PORT="${SLACK_IMPORT_PORT:-8765}" + +exec python3 server.py --export "$SLACK_EXPORT_PATH" --port "$PORT" diff --git a/scripts/hydrate-export.sh b/scripts/hydrate-export.sh new file mode 100755 index 0000000..5026be0 --- /dev/null +++ b/scripts/hydrate-export.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Download Slack export attachments using token= URLs embedded in the official export. +# Official exports include xoxe-... tokens on file URLs; slackdump hydrate rejects them. +# +# Usage: +# ./scripts/slack-hydrate-export.sh +# SLACK_EXPORT_DIR=... SLACK_HYDRATED_DIR=... ./scripts/slack-hydrate-export.sh + +set -euo pipefail + +SLACK_EXPORT_DIR="${SLACK_EXPORT_DIR:-$HOME/Downloads/Levkin Slack export Sep 29 2016 - May 27 2026}" +SLACK_HYDRATED_DIR="${SLACK_HYDRATED_DIR:-$HOME/Downloads/Levkin Slack export enriched}" +JOBS="${SLACK_HYDRATE_JOBS:-8}" + +if [[ ! -d "$SLACK_EXPORT_DIR" ]]; then + echo "Export folder not found: $SLACK_EXPORT_DIR" >&2 + exit 1 +fi + +echo "==> Copying export JSON to $SLACK_HYDRATED_DIR (rsync, no deletes)..." +mkdir -p "$SLACK_HYDRATED_DIR" +rsync -a \ + --exclude "__uploads" \ + --exclude ".DS_Store" \ + "$SLACK_EXPORT_DIR/" "$SLACK_HYDRATED_DIR/" + +echo "==> Downloading attachments (parallel jobs=$JOBS)..." +python3 - "$SLACK_EXPORT_DIR" "$SLACK_HYDRATED_DIR" "$JOBS" <<'PY' +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +export_dir = Path(sys.argv[1]) +hydrated_dir = Path(sys.argv[2]) +jobs = int(sys.argv[3]) + +SKIP_ROOT = { + "channels.json", + "users.json", + "dms.json", + "groups.json", + "mpims.json", + "canvases.json", + "lists.json", + "huddle_transcripts.json", + "integration_logs.json", + "file_conversations.json", +} + + +def iter_files(): + for path in export_dir.rglob("*.json"): + if path.name in SKIP_ROOT: + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + continue + if not isinstance(data, list): + continue + for msg in data: + if not isinstance(msg, dict): + continue + for f in msg.get("files") or []: + if not isinstance(f, dict): + continue + fid = f.get("id") + name = f.get("name") or "file" + url = f.get("url_private_download") or f.get("url_private") + if not fid or not url: + continue + yield fid, name, url.replace("\\/", "/") + + +def download(fid: str, name: str, url: str) -> tuple[str, str]: + dest_dir = hydrated_dir / "__uploads" / fid + dest = dest_dir / name + if dest.exists() and dest.stat().st_size > 0: + return ("skip", str(dest)) + dest_dir.mkdir(parents=True, exist_ok=True) + req = urllib.request.Request(url, headers={"User-Agent": "slack-sieve-hydrate/1.0"}) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + data = resp.read() + except urllib.error.HTTPError as exc: + return ("fail", f"{fid}/{name}: HTTP {exc.code}") + except Exception as exc: # noqa: BLE001 + return ("fail", f"{fid}/{name}: {exc}") + dest.write_bytes(data) + return ("ok", str(dest)) + + +tasks = list({(fid, name, url) for fid, name, url in iter_files()}) +print(f"Found {len(tasks)} unique attachments") + +ok = skip = fail = 0 +with ThreadPoolExecutor(max_workers=jobs) as pool: + futures = {pool.submit(download, *t): t for t in tasks} + for i, fut in enumerate(as_completed(futures), 1): + status, detail = fut.result() + if status == "ok": + ok += 1 + elif status == "skip": + skip += 1 + else: + fail += 1 + print(detail, file=sys.stderr) + if i % 100 == 0 or i == len(tasks): + print(f" progress {i}/{len(tasks)} ok={ok} skip={skip} fail={fail}") + +print(f"Done: ok={ok} skip={skip} fail={fail}") +print(f"Hydrated export: {hydrated_dir}") +print(f"Review: SLACK_EXPORT_PATH='{hydrated_dir}' make review") +if fail and ok == 0: + sys.exit(1) +if fail: + print(f"Warning: {fail} files failed (often expired export token on old uploads).", file=sys.stderr) +PY diff --git a/scripts/preset-skips.py b/scripts/preset-skips.py new file mode 100755 index 0000000..8e54aed --- /dev/null +++ b/scripts/preset-skips.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Pre-mark channels as skip in manifest.json using a JSON preset file.""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MANIFEST_PATH = REPO_ROOT / "manifest.json" +DEFAULT_PRESET = REPO_ROOT / "presets" / "levkin-skips.json" +DEFAULT_EXPORT = Path.home() / "Downloads/Levkin Slack export enriched" + + +def load_preset(path: Path) -> dict: + data = json.loads(path.read_text(encoding="utf-8")) + skip_a = set(data.get("tier_a") or []) + skip_b = set(data.get("tier_b") or []) + skip_other = set(data.get("other") or []) + return { + "skip_all": skip_a | skip_b | skip_other, + "tier_a": skip_a, + "tier_b": skip_b, + "review_priority": data.get("review_priority") or [], + "review_later": data.get("review_later") or [], + } + + +def channel_entry_skip(notes: str) -> dict: + return { + "import": False, + "status": "skip", + "reviewed": True, + "import_mode": "all", + "notes": notes, + "mattermost_channel": "", + "mattermost_team": "", + "messages": {}, + } + + +def main() -> int: + export_path = Path( + sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-") else DEFAULT_EXPORT + ).expanduser().resolve() + preset_path = DEFAULT_PRESET + for i, arg in enumerate(sys.argv[1:], 1): + if arg == "--preset" and i < len(sys.argv) - 1: + preset_path = Path(sys.argv[i + 1]).expanduser().resolve() + + if not export_path.is_dir(): + print(f"Export not found: {export_path}", file=sys.stderr) + return 1 + if not preset_path.is_file(): + print(f"Preset not found: {preset_path}", file=sys.stderr) + return 1 + + preset = load_preset(preset_path) + skip_all = preset["skip_all"] + + channels_json = export_path / "channels.json" + if not channels_json.is_file(): + print(f"No channels.json in {export_path}", file=sys.stderr) + return 1 + + all_names = {c["name"] for c in json.loads(channels_json.read_text()) if c.get("name")} + for d in export_path.iterdir(): + if d.is_dir() and any(d.glob("*.json")): + all_names.add(d.name) + + manifest: dict = { + "version": 1, + "export_path": str(export_path), + "updated_at": datetime.now(tz=timezone.utc).isoformat(), + "global_notes": f"Preset skips from {preset_path.name}", + "channels": {}, + "users": {}, + } + + if MANIFEST_PATH.is_file(): + existing = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + if isinstance(existing.get("users"), dict): + manifest["users"] = existing["users"] + for name, entry in (existing.get("channels") or {}).items(): + if name not in skip_all and isinstance(entry, dict): + manifest["channels"][name] = entry + + skipped = [] + for name in sorted(skip_all): + if name in all_names or (export_path / name).is_dir(): + tier = ( + "tier-a" + if name in preset["tier_a"] + else "tier-b" + if name in preset["tier_b"] + else "other" + ) + manifest["channels"][name] = channel_entry_skip( + f"Preset skip ({tier})" + ) + skipped.append(name) + + undecided = [ + n + for n in sorted(all_names) + if n not in skip_all and n not in manifest["channels"] + ] + + MANIFEST_PATH.write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + print(f"Wrote {MANIFEST_PATH}") + print(f" Skipped ({len(skipped)}): {', '.join('#' + n for n in skipped)}") + print(f" Still undecided ({len(undecided)}): {', '.join('#' + n for n in undecided)}") + print("\nSuggested review order:") + for n in preset["review_priority"]: + if n in undecided: + print(f" → #{n}") + for n in sorted(preset["review_later"]): + if n in undecided: + print(f" (later) #{n}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server.py b/server.py new file mode 100755 index 0000000..ed75d3c --- /dev/null +++ b/server.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +"""Local UI server for reviewing Slack export before Mattermost import.""" + +from __future__ import annotations + +import argparse +import html +import json +import mimetypes +import os +import sys +import zipfile +import xml.etree.ElementTree as ET +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlparse + +from slack_export import SlackExport, resolve_paths + +TOOL_DIR = Path(__file__).resolve().parent +STATIC_DIR = TOOL_DIR / "static" + + +def default_manifest(export_path: str) -> dict: + return { + "version": 1, + "export_path": export_path, + "updated_at": None, + "global_notes": "", + "channels": {}, + "users": {}, + } + + +def load_manifest(path: Path, export_path: str) -> dict: + if path.is_file(): + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + return default_manifest(export_path) + + +def save_manifest(path: Path, data: dict) -> None: + data["updated_at"] = datetime.now(tz=timezone.utc).isoformat() + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +_W_NS = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + + +def docx_to_html(path: Path) -> str: + """Minimal DOCX → HTML using stdlib only (fallback when browser mammoth fails).""" + with zipfile.ZipFile(path) as zf: + xml_bytes = zf.read("word/document.xml") + root = ET.fromstring(xml_bytes) + blocks: list[str] = [] + for para in root.iter(f"{_W_NS}p"): + runs = [node.text for node in para.iter(f"{_W_NS}t") if node.text] + line = "".join(runs).strip() + if line: + blocks.append(f"

{html.escape(line)}

") + return "".join(blocks) or "

Empty document

" + + +def file_preview_payload_bytes(raw: bytes, name: str) -> dict: + lower_name = name.lower() + + if raw[:2] == b"PK": + try: + import io + + with zipfile.ZipFile(io.BytesIO(raw)) as zf: + xml_bytes = zf.read("word/document.xml") + root = ET.fromstring(xml_bytes) + blocks: list[str] = [] + for para in root.iter(f"{_W_NS}p"): + runs = [node.text for node in para.iter(f"{_W_NS}t") if node.text] + line = "".join(runs).strip() + if line: + blocks.append(f"

{html.escape(line)}

") + body = "".join(blocks) or "

Empty document

" + return {"ok": True, "kind": "html", "html": body} + except (zipfile.BadZipFile, KeyError, ET.ParseError) as exc: + return {"ok": False, "error": f"Could not read DOCX: {exc}"} + + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + return {"ok": False, "error": "Binary file — use download."} + + lower = text.lower() + if ( + "quip-canvas-content" in lower + or lower.lstrip().startswith(("{html.escape(text)}", + } + + return {"ok": False, "error": "Unsupported format for preview."} + + +def file_preview_payload(path: Path) -> dict: + return file_preview_payload_bytes(path.read_bytes(), path.name) + + +class ReviewHandler(BaseHTTPRequestHandler): + export: SlackExport + manifest_path: Path + + def log_message(self, format: str, *args) -> None: # noqa: A003 + if os.environ.get("SLACK_IMPORT_QUIET") != "1": + super().log_message(format, *args) + + def _send_json(self, payload: object, status: int = HTTPStatus.OK) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def _send_file(self, path: Path) -> None: + if not path.is_file(): + self.send_error(HTTPStatus.NOT_FOUND) + return + content = path.read_bytes() + mime, _ = mimetypes.guess_type(str(path)) + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", mime or "application/octet-stream") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + + def _safe_export_path(self, relpath: str) -> Path | None: + """ + Resolve a relative path within the Slack export root. + Prevent path traversal and absolute paths. + """ + if not relpath or relpath.startswith(("/", "\\")) or ".." in relpath: + return None + root = self.export.paths.root.resolve() + candidate = (root / relpath).resolve() + try: + candidate.relative_to(root) + except ValueError: + return None + return candidate + + def _read_json_body(self) -> dict | None: + length = int(self.headers.get("Content-Length", 0)) + if length <= 0: + return None + raw = self.rfile.read(length) + try: + data = json.loads(raw.decode("utf-8")) + return data if isinstance(data, dict) else None + except json.JSONDecodeError: + return None + + def do_OPTIONS(self) -> None: # noqa: N802 + self.send_response(HTTPStatus.NO_CONTENT) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + path = parsed.path + qs = parse_qs(parsed.query) + + if path in ("/", "/index.html"): + return self._send_file(STATIC_DIR / "index.html") + if path.startswith("/static/"): + rel = path.removeprefix("/static/") + return self._send_file(STATIC_DIR / rel) + + if path.startswith("/uploads/"): + rel = unquote(path.removeprefix("/uploads/")) + export_file = self._safe_export_path(rel) + if not export_file: + self.send_error(HTTPStatus.BAD_REQUEST) + return + return self._send_file(export_file) + + if path == "/api/file-preview": + file_id = (qs.get("file_id") or [""])[0].strip() + if file_id: + try: + raw, rec = self.export.fetch_file_bytes(file_id) + return self._send_json( + file_preview_payload_bytes(raw, rec.get("name") or "file") + ) + except FileNotFoundError as exc: + return self._send_json( + {"ok": False, "error": str(exc)}, + status=HTTPStatus.NOT_FOUND, + ) + + rel = unquote((qs.get("rel") or [""])[0]) + export_file = self._safe_export_path(rel) + if not export_file or not export_file.is_file(): + return self._send_json( + { + "ok": False, + "error": "File not on disk — standard Slack exports omit attachments; use file preview (fetches from Slack once).", + }, + status=HTTPStatus.NOT_FOUND, + ) + return self._send_json(file_preview_payload(export_file)) + + if path == "/api/file-download": + file_id = (qs.get("file_id") or [""])[0].strip() + if not file_id: + self.send_error(HTTPStatus.BAD_REQUEST) + return + try: + raw, rec = self.export.fetch_file_bytes(file_id) + except FileNotFoundError: + self.send_error(HTTPStatus.NOT_FOUND) + return + mime, _ = mimetypes.guess_type(rec.get("name") or "") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", mime or "application/octet-stream") + self.send_header( + "Content-Disposition", + f'inline; filename="{rec.get("name", "file")}"', + ) + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + + if path == "/api/health": + return self._send_json( + { + "ok": True, + "export_path": str(self.export.paths.root), + "manifest_path": str(self.manifest_path), + } + ) + + if path == "/api/summary": + summary = self.export.build_summary() + manifest = load_manifest( + self.manifest_path, str(self.export.paths.root) + ) + selected_channels = sum( + 1 + for c in manifest.get("channels", {}).values() + if c.get("import") + ) + return self._send_json( + { + **summary, + "manifest": { + "updated_at": manifest.get("updated_at"), + "selected_channels": selected_channels, + "global_notes": manifest.get("global_notes", ""), + }, + } + ) + + if path == "/api/channels": + query = (qs.get("q") or [""])[0] + channels = self.export.search_channels(query) + manifest = load_manifest( + self.manifest_path, str(self.export.paths.root) + ) + channel_manifest = manifest.get("channels") or {} + for ch in channels: + saved = channel_manifest.get(ch["name"], {}) + ch["import"] = bool(saved.get("import")) + ch["status"] = saved.get("status") or ( + "import" if saved.get("import") else "undecided" + ) + ch["reviewed"] = bool(saved.get("reviewed")) or ch["status"] in ( + "import", + "skip", + ) + ch["notes"] = saved.get("notes", "") + ch["mattermost_channel"] = saved.get("mattermost_channel", "") + ch["mattermost_team"] = saved.get("mattermost_team", "") + ch["import_mode"] = saved.get("import_mode") or "all" + ch["messages"] = saved.get("messages") or {} + ch["message_selections"] = ch["messages"] + return self._send_json({"channels": channels}) + + if path.startswith("/api/channels/") and path.endswith("/messages/all"): + channel_name = path.split("/")[3] + return self._send_json(self.export.channel_messages_all(channel_name)) + + if path.startswith("/api/channels/") and path.endswith("/messages"): + channel_name = path.split("/")[3] + date = (qs.get("date") or [None])[0] + offset = int((qs.get("offset") or ["0"])[0]) + limit = min(int((qs.get("limit") or ["100"])[0]), 500) + return self._send_json( + self.export.channel_messages_page( + channel_name, date=date, offset=offset, limit=limit + ) + ) + + if path == "/api/search": + query = (qs.get("q") or [""])[0] + limit = min(int((qs.get("limit") or ["80"])[0]), 200) + return self._send_json(self.export.search_global(query, limit=limit)) + + if path.startswith("/api/channels/") and path.endswith("/search"): + channel_name = path.split("/")[3] + query = (qs.get("q") or [""])[0] + limit = min(int((qs.get("limit") or ["50"])[0]), 200) + hits = self.export.search_messages(channel_name, query, limit=limit) + return self._send_json({"channel": channel_name, "query": query, "hits": hits}) + + if path == "/api/users": + manifest = load_manifest( + self.manifest_path, str(self.export.paths.root) + ) + user_manifest = manifest.get("users") or {} + users = [] + for user in self.export.users_raw: + uid = user.get("id") + saved = user_manifest.get(uid, {}) + users.append( + { + "id": uid, + "name": user.get("name"), + "real_name": user.get("profile", {}).get("real_name") + or user.get("real_name"), + "is_bot": bool(user.get("is_bot")), + "deleted": bool(user.get("deleted")), + "import": bool(saved.get("import")), + "notes": saved.get("notes", ""), + "mattermost_username": saved.get("mattermost_username", ""), + } + ) + return self._send_json({"users": users}) + + if path == "/api/manifest": + manifest = load_manifest( + self.manifest_path, str(self.export.paths.root) + ) + return self._send_json(manifest) + + self.send_error(HTTPStatus.NOT_FOUND) + + def do_POST(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != "/api/manifest": + self.send_error(HTTPStatus.NOT_FOUND) + return + + body = self._read_json_body() + if body is None: + return self._send_json( + {"error": "invalid JSON body"}, status=HTTPStatus.BAD_REQUEST + ) + + export_path = str(self.export.paths.root) + current = load_manifest(self.manifest_path, export_path) + + if "global_notes" in body: + current["global_notes"] = body.get("global_notes") or "" + + if isinstance(body.get("channels"), dict): + current.setdefault("channels", {}) + for name, entry in body["channels"].items(): + if not isinstance(entry, dict): + continue + status = entry.get("status") or ( + "import" if entry.get("import") else "undecided" + ) + import_mode = entry.get("import_mode") or "all" + if import_mode not in ("all", "pick"): + import_mode = "all" + saved_messages = entry.get("messages") or entry.get("message_selections") + if not isinstance(saved_messages, dict): + saved_messages = {} + current["channels"][name] = { + "import": status in ("import", "partial"), + "status": status, + "reviewed": bool(entry.get("reviewed")) + or status in ("import", "skip", "partial"), + "import_mode": import_mode, + "notes": entry.get("notes") or "", + "mattermost_channel": entry.get("mattermost_channel") or "", + "mattermost_team": entry.get("mattermost_team") or "", + "messages": saved_messages, + } + + if isinstance(body.get("users"), dict): + current.setdefault("users", {}) + for uid, entry in body["users"].items(): + if not isinstance(entry, dict): + continue + current["users"][uid] = { + "import": bool(entry.get("import")), + "notes": entry.get("notes") or "", + "mattermost_username": entry.get("mattermost_username") or "", + } + + current["export_path"] = export_path + current["version"] = 1 + save_manifest(self.manifest_path, current) + return self._send_json({"ok": True, "manifest": current}) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--export", + default=os.environ.get("SLACK_EXPORT_PATH"), + help="Path to Slack export directory", + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + + paths = resolve_paths(args.export) + try: + export = SlackExport(paths.root) + except FileNotFoundError as exc: + print(exc, file=sys.stderr) + print( + "\nSet SLACK_EXPORT_PATH or pass --export to your Slack export folder.", + file=sys.stderr, + ) + return 1 + + handler_cls = type( + "BoundReviewHandler", + (ReviewHandler,), + {"export": export, "manifest_path": paths.manifest_json}, + ) + + server = ThreadingHTTPServer((args.host, args.port), handler_cls) + print(f"Slack import review UI: http://{args.host}:{args.port}") + print(f"Export: {paths.root}") + print(f"Manifest: {paths.manifest_json}") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/slack_export.py b/slack_export.py new file mode 100644 index 0000000..f8d1c17 --- /dev/null +++ b/slack_export.py @@ -0,0 +1,506 @@ +"""Read a standard Slack workspace export directory.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +DEFAULT_EXPORT = Path.home() / "Downloads" / "slack-export" + + +@dataclass(frozen=True) +class ExportPaths: + root: Path + channels_json: Path + users_json: Path + manifest_json: Path + + +def resolve_paths(export_root: str | Path | None = None) -> ExportPaths: + root = Path(export_root or DEFAULT_EXPORT).expanduser().resolve() + tool_dir = Path(__file__).resolve().parent + return ExportPaths( + root=root, + channels_json=root / "channels.json", + users_json=root / "users.json", + manifest_json=tool_dir / "manifest.json", + ) + + +def load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def slack_ts_to_iso(ts: str | float | None) -> str | None: + if ts is None: + return None + try: + seconds = float(str(ts).split(".")[0]) + return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat() + except (TypeError, ValueError): + return None + + +def user_display(user: dict[str, Any] | None) -> str: + if not user: + return "unknown" + profile = user.get("profile") or {} + return ( + profile.get("display_name") + or profile.get("real_name") + or user.get("real_name") + or user.get("name") + or user.get("id") + or "unknown" + ) + + +class SlackExport: + def __init__(self, export_root: str | Path | None = None) -> None: + self.paths = resolve_paths(export_root) + if not self.paths.root.is_dir(): + raise FileNotFoundError(f"Slack export not found: {self.paths.root}") + + self.channels_meta: list[dict[str, Any]] = [] + if self.paths.channels_json.is_file(): + self.channels_meta = load_json(self.paths.channels_json) + + self.users_raw: list[dict[str, Any]] = [] + if self.paths.users_json.is_file(): + self.users_raw = load_json(self.paths.users_json) + + self.users_by_id = {u["id"]: u for u in self.users_raw if u.get("id")} + self.channel_meta_by_name = { + c["name"]: c for c in self.channels_meta if c.get("name") + } + self._file_index: dict[str, dict[str, Any]] | None = None + + @staticmethod + def upload_relpath(file_id: str, file_name: str) -> str: + return f"__uploads/{file_id}/{file_name}" + + def local_upload_path(self, file_id: str, file_name: str) -> Path: + return self.paths.root / self.upload_relpath(file_id, file_name) + + def _register_file_raw(self, f: dict[str, Any]) -> None: + file_id = f.get("id") + file_name = f.get("name") + if not file_id or not file_name: + return + rel = self.upload_relpath(file_id, file_name) + local = self.paths.root / rel + self._file_index[file_id] = { + "id": file_id, + "name": file_name, + "mimetype": f.get("mimetype"), + "filetype": f.get("filetype"), + "url_private_download": f.get("url_private_download") or f.get("url_private"), + "local_relpath": rel, + "local_exists": local.is_file(), + } + + def ensure_file_index(self) -> dict[str, dict[str, Any]]: + if self._file_index is not None: + return self._file_index + self._file_index = {} + canvases_path = self.paths.root / "canvases.json" + if canvases_path.is_file(): + data = load_json(canvases_path) + if isinstance(data, list): + for entry in data: + if isinstance(entry, dict): + self._register_file_raw(entry) + for ch_dir in self.channel_dirs(): + for json_path in ch_dir.glob("*.json"): + data = load_json(json_path) + if not isinstance(data, list): + continue + for msg in data: + if not isinstance(msg, dict): + continue + for raw_file in msg.get("files") or []: + if isinstance(raw_file, dict): + self._register_file_raw(raw_file) + return self._file_index + + def file_record(self, file_id: str) -> dict[str, Any] | None: + return self.ensure_file_index().get(file_id) + + def fetch_file_bytes(self, file_id: str) -> tuple[bytes, dict[str, Any]]: + """Read cached upload or download from Slack using export token in URL.""" + import urllib.error + import urllib.request + + rec = self.file_record(file_id) + if not rec: + raise FileNotFoundError(f"Unknown file id: {file_id}") + + local = self.paths.root / rec["local_relpath"] + if local.is_file(): + return local.read_bytes(), rec + + url = rec.get("url_private_download") + if not url: + raise FileNotFoundError(f"No download URL for {file_id}") + + headers: dict[str, str] = {} + if "token=" in url: + token = url.split("token=", 1)[1].split("&")[0] + headers["Authorization"] = f"Bearer {token}" + + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=90) as resp: + data = resp.read() + except urllib.error.HTTPError as exc: + raise FileNotFoundError( + f"Slack download failed ({exc.code}). Export token may be expired." + ) from exc + + local.parent.mkdir(parents=True, exist_ok=True) + local.write_bytes(data) + rec["local_exists"] = True + return data, rec + + def channel_dirs(self) -> list[Path]: + skip = { + "channels.json", + "users.json", + "canvases.json", + "file_conversations.json", + "huddle_transcripts.json", + "integration_logs.json", + "lists.json", + } + dirs = [] + for entry in sorted(self.paths.root.iterdir(), key=lambda p: p.name.lower()): + if entry.is_dir() and entry.name not in skip: + dirs.append(entry) + return dirs + + def channel_folder(self, name: str) -> Path | None: + folder = self.paths.root / name + return folder if folder.is_dir() else None + + def list_channel_date_files(self, channel_name: str) -> list[str]: + folder = self.channel_folder(channel_name) + if not folder: + return [] + return sorted(f.stem for f in folder.glob("*.json")) + + def read_messages_file(self, channel_name: str, date: str) -> list[dict[str, Any]]: + folder = self.channel_folder(channel_name) + if not folder: + return [] + path = folder / f"{date}.json" + if not path.is_file(): + return [] + data = load_json(path) + return data if isinstance(data, list) else [] + + def normalize_message( + self, msg: dict[str, Any], channel_name: str, date_file: str + ) -> dict[str, Any]: + user_id = msg.get("user") or msg.get("bot_id") + user = self.users_by_id.get(user_id) if user_id else None + subtype = msg.get("subtype") + text = (msg.get("text") or "").strip() + + if subtype == "channel_join" and not text: + who = user_display(user) + text = f"{who} joined the channel" + elif subtype == "channel_leave" and not text: + who = user_display(user) + text = f"{who} left the channel" + elif subtype == "channel_topic" and msg.get("topic"): + text = f"set topic: {msg['topic']}" + elif subtype == "channel_purpose" and msg.get("purpose"): + text = f"set purpose: {msg['purpose']}" + elif subtype == "file_share" and msg.get("files"): + names = ", ".join(f.get("name", "file") for f in msg["files"]) + text = text or f"shared file(s): {names}" + elif not text and msg.get("attachments"): + text = "[attachment]" + elif not text and msg.get("blocks"): + text = "[rich message]" + + files = [] + for f in msg.get("files") or []: + file_id = f.get("id") + file_name = f.get("name") + rel = ( + self.upload_relpath(file_id, file_name) + if file_id and file_name + else None + ) + local_exists = False + if rel: + local_exists = (self.paths.root / rel).is_file() + files.append( + { + "id": file_id, + "name": file_name, + "mimetype": f.get("mimetype"), + "filetype": f.get("filetype"), + "url_private": f.get("url_private"), + "url_private_download": f.get("url_private_download"), + "local_relpath": rel, + "local_exists": local_exists, + } + ) + + ts = msg.get("ts") + thread_ts = msg.get("thread_ts") + is_thread_reply = bool( + thread_ts and ts and str(thread_ts) != str(ts) + ) + thread_root_ts = str(thread_ts) if thread_ts else str(ts or "") + + return { + "id": f"{channel_name}:{ts}", + "channel": channel_name, + "date_file": date_file, + "ts": ts, + "time": slack_ts_to_iso(ts), + "user_id": user_id, + "user_name": user_display(user), + "username": (user or {}).get("name"), + "is_bot": bool((user or {}).get("is_bot")) or bool(msg.get("bot_id")), + "subtype": subtype, + "type": msg.get("type"), + "text": text, + "thread_ts": thread_ts, + "thread_root_ts": thread_root_ts, + "is_thread_reply": is_thread_reply, + "is_thread_parent": bool(msg.get("reply_count")), + "reply_count": msg.get("reply_count") or 0, + "files": files, + "reactions": msg.get("reactions") or [], + "raw_has_blocks": bool(msg.get("blocks")), + } + + def attach_thread_reply_counts( + self, messages: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Add thread_reply_count on parent messages from export data.""" + counts: dict[str, int] = {} + for msg in messages: + if msg.get("is_thread_reply"): + root = str(msg.get("thread_root_ts", "")) + counts[root] = counts.get(root, 0) + 1 + for msg in messages: + ts = str(msg.get("ts", "")) + if msg.get("is_thread_parent") or counts.get(ts, 0) > 0: + msg["thread_reply_count"] = max( + int(msg.get("reply_count") or 0), + counts.get(ts, 0), + ) + else: + msg["thread_reply_count"] = 0 + return messages + + def channel_stats(self, channel_name: str) -> dict[str, Any]: + folder = self.channel_folder(channel_name) + meta = self.channel_meta_by_name.get(channel_name, {}) + if not folder: + return { + "name": channel_name, + "exists_in_export": False, + "message_count": 0, + "file_count": 0, + "first_date": None, + "last_date": None, + "is_archived": meta.get("is_archived", False), + "is_general": meta.get("is_general", False), + "purpose": (meta.get("purpose") or {}).get("value", ""), + "topic": (meta.get("topic") or {}).get("value", ""), + "member_count": len(meta.get("members") or []), + "slack_id": meta.get("id"), + } + + dates = self.list_channel_date_files(channel_name) + message_count = 0 + for date in dates: + message_count += len(self.read_messages_file(channel_name, date)) + + return { + "name": channel_name, + "exists_in_export": True, + "message_count": message_count, + "file_count": len(dates), + "first_date": dates[0] if dates else None, + "last_date": dates[-1] if dates else None, + "is_archived": meta.get("is_archived", False), + "is_general": meta.get("is_general", False), + "purpose": (meta.get("purpose") or {}).get("value", ""), + "topic": (meta.get("topic") or {}).get("value", ""), + "member_count": len(meta.get("members") or []), + "slack_id": meta.get("id"), + } + + def build_summary(self) -> dict[str, Any]: + channels = [] + total_messages = 0 + for folder in self.channel_dirs(): + stats = self.channel_stats(folder.name) + channels.append(stats) + total_messages += stats["message_count"] + + users = [] + for user in self.users_raw: + users.append( + { + "id": user.get("id"), + "name": user.get("name"), + "real_name": user_display(user), + "is_bot": bool(user.get("is_bot")), + "deleted": bool(user.get("deleted")), + "is_admin": bool(user.get("is_admin")), + } + ) + + return { + "export_path": str(self.paths.root), + "channel_count": len(channels), + "user_count": len(users), + "total_messages": total_messages, + "channels": channels, + "users": users, + } + + def search_channels(self, query: str) -> list[dict[str, Any]]: + q = query.strip().lower() + summary = self.build_summary()["channels"] + if not q: + return summary + return [ + c + for c in summary + if q in c["name"].lower() + or q in (c.get("purpose") or "").lower() + or q in (c.get("topic") or "").lower() + ] + + def iter_channel_messages(self, channel_name: str) -> list[dict[str, Any]]: + """All messages in a channel, oldest first.""" + messages: list[dict[str, Any]] = [] + for date in self.list_channel_date_files(channel_name): + for raw in self.read_messages_file(channel_name, date): + messages.append(self.normalize_message(raw, channel_name, date)) + + def sort_key(msg: dict[str, Any]) -> float: + try: + return float(str(msg.get("ts", "0")).split(".")[0]) + except (TypeError, ValueError): + return 0.0 + + messages.sort(key=sort_key) + return self.attach_thread_reply_counts(messages) + + def channel_messages_all(self, channel_name: str) -> dict[str, Any]: + messages = self.iter_channel_messages(channel_name) + thread_parents = sum( + 1 for m in messages if m.get("is_thread_parent") or m.get("thread_reply_count") + ) + with_files = sum(1 for m in messages if m.get("files")) + return { + "channel": channel_name, + "messages": messages, + "total": len(messages), + "thread_parents": thread_parents, + "messages_with_files": with_files, + } + + def search_global( + self, query: str, *, limit: int = 80 + ) -> dict[str, Any]: + pattern = re.compile(re.escape(query.strip()), re.IGNORECASE) + if not query.strip(): + return {"query": query, "channels": [], "messages": []} + + channel_hits: list[dict[str, Any]] = [] + message_hits: list[dict[str, Any]] = [] + + for ch in self.build_summary()["channels"]: + name = ch["name"] + if pattern.search(name) or pattern.search(ch.get("purpose") or ""): + channel_hits.append(ch) + + for folder in self.channel_dirs(): + if len(message_hits) >= limit: + break + name = folder.name + for date in self.list_channel_date_files(name): + if len(message_hits) >= limit: + break + for raw in self.read_messages_file(name, date): + normalized = self.normalize_message(raw, name, date) + if pattern.search(normalized.get("text") or ""): + message_hits.append(normalized) + if len(message_hits) >= limit: + break + + return { + "query": query, + "channels": channel_hits[:30], + "messages": message_hits, + } + + def channel_messages_page( + self, + channel_name: str, + *, + date: str | None = None, + offset: int = 0, + limit: int = 100, + ) -> dict[str, Any]: + dates = self.list_channel_date_files(channel_name) + if not dates: + return { + "channel": channel_name, + "dates": [], + "date": None, + "messages": [], + "offset": 0, + "limit": limit, + "total_in_date": 0, + "has_more": False, + } + + active_date = date if date in dates else dates[-1] + raw = self.read_messages_file(channel_name, active_date) + normalized = [ + self.normalize_message(m, channel_name, active_date) for m in raw + ] + page = normalized[offset : offset + limit] + return { + "channel": channel_name, + "dates": dates, + "date": active_date, + "messages": page, + "offset": offset, + "limit": limit, + "total_in_date": len(normalized), + "has_more": offset + limit < len(normalized), + } + + def search_messages( + self, channel_name: str, query: str, *, limit: int = 50 + ) -> list[dict[str, Any]]: + pattern = re.compile(re.escape(query.strip()), re.IGNORECASE) + if not query.strip(): + return [] + hits: list[dict[str, Any]] = [] + for date in reversed(self.list_channel_date_files(channel_name)): + for msg in self.read_messages_file(channel_name, date): + normalized = self.normalize_message(msg, channel_name, date) + if pattern.search(normalized.get("text") or ""): + hits.append(normalized) + if len(hits) >= limit: + return hits + return hits diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..7769626 --- /dev/null +++ b/static/app.css @@ -0,0 +1,887 @@ +:root { + --bg: #0c0e14; + --panel: #141820; + --panel-2: #1a2030; + --border: #2a3348; + --text: #e8ecf4; + --muted: #8b95a8; + --accent: #5b8def; + --import: #2ecc87; + --import-bg: rgba(46, 204, 135, 0.12); + --skip: #e06c75; + --skip-bg: rgba(224, 108, 117, 0.12); + --undecided: #f0b429; + --highlight: rgba(91, 141, 239, 0.35); + font-family: "SF Pro Text", "Segoe UI", system-ui, sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + height: 100vh; + overflow: hidden; +} + +.app { + display: grid; + grid-template-columns: 280px 1fr 280px; + grid-template-rows: auto 1fr; + height: 100vh; +} + +header.topbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +.topbar h1 { + margin: 0; + font-size: 0.95rem; + white-space: nowrap; +} + +.topbar .stats { + display: flex; + gap: 0.75rem; + color: var(--muted); + font-size: 0.8rem; + white-space: nowrap; +} + +.global-search-wrap { + flex: 1; + position: relative; + max-width: 480px; +} + +.global-search-wrap input { + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + padding: 0.45rem 0.65rem; + font-size: 0.9rem; +} + +.search-results { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + max-height: 360px; + overflow: auto; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 8px; + z-index: 100; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); +} + +.search-results.hidden { display: none; } + +.search-group { + padding: 0.35rem 0; + border-bottom: 1px solid var(--border); +} + +.search-group h4 { + margin: 0; + padding: 0.35rem 0.65rem; + font-size: 0.7rem; + text-transform: uppercase; + color: var(--muted); + letter-spacing: 0.04em; +} + +.search-hit { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + color: var(--text); + padding: 0.45rem 0.65rem; + cursor: pointer; + font-size: 0.85rem; +} + +.search-hit:hover { background: var(--highlight); } + +.search-hit .sub { + color: var(--muted); + font-size: 0.75rem; + margin-top: 0.15rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +button, input, textarea, select { + font: inherit; +} + +button { + border-radius: 8px; + padding: 0.4rem 0.7rem; + cursor: pointer; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.panel { + background: var(--panel); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + min-height: 0; +} + +.panel.right { + border-right: none; + border-left: 1px solid var(--border); +} + +.panel-header { + padding: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.panel-header input, +.panel-header select { + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 0.4rem 0.55rem; + font-size: 0.85rem; +} + +.filter-row { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-top: 0.35rem; +} + +.chk { + font-size: 0.78rem; + color: var(--muted); + display: flex; + align-items: center; + gap: 0.35rem; +} + +.progress-bar { + padding: 0.35rem 0.65rem; + font-size: 0.75rem; + color: var(--muted); + border-bottom: 1px solid var(--border); +} + +.progress-bar strong { color: var(--text); } + +.tabs { + display: flex; + padding: 0.35rem 0.5rem; + gap: 0.25rem; +} + +.tabs button { + flex: 1; + font-size: 0.8rem; +} + +.tabs button.active { + background: rgba(91, 141, 239, 0.2); + border-color: var(--accent); +} + +.list { overflow: auto; flex: 1; } + +.list-item { + display: flex; + align-items: flex-start; + gap: 0.45rem; + width: 100%; + text-align: left; + border: none; + border-bottom: 1px solid var(--border); + background: transparent; + color: inherit; + padding: 0.55rem 0.65rem; + cursor: pointer; +} + +.list-item:hover, +.list-item.active { background: rgba(91, 141, 239, 0.1); } + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 0.35rem; + flex-shrink: 0; +} + +.status-dot.import { background: var(--import); } +.status-dot.skip { background: var(--skip); } +.status-dot.undecided { background: var(--undecided); } +.status-dot.partial { background: var(--accent); } + +.list-item .body { flex: 1; min-width: 0; } + +.list-item .name { + font-weight: 600; + font-size: 0.88rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.list-item .meta { + color: var(--muted); + font-size: 0.72rem; + margin-top: 0.1rem; +} + +.center { + display: flex; + flex-direction: column; + min-height: 0; + background: var(--bg); +} + +.triage-bar { + display: grid; + grid-template-columns: 168px 1fr 300px; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 0.75rem; + border-bottom: 1px solid var(--border); + background: var(--panel); + min-height: 88px; + flex-shrink: 0; +} + +.triage-nav, +.triage-decide { + display: flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; +} + +.triage-decide { + justify-content: flex-end; + flex-wrap: wrap; +} + +.triage-center { + min-width: 0; + min-height: 3.2rem; +} + +.triage-center h2 { + margin: 0; + font-size: 1.05rem; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.triage-meta { + display: block; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.35; + min-height: 2.1em; +} + +.mode-toggle { + font-size: 0.72rem; + white-space: nowrap; + margin-right: 0.25rem; +} + +.btn-nav { + width: 76px; + min-width: 76px; + padding: 0.45rem 0.35rem; + font-size: 0.8rem; +} + +.btn-import { + background: var(--import); + border-color: var(--import); + color: #0a1a12; + font-weight: 600; + width: 118px; + min-width: 118px; +} + +.btn-skip { + background: transparent; + border-color: var(--skip); + color: var(--skip); + font-weight: 600; + width: 108px; + min-width: 108px; +} + +.triage-decide button:disabled, +.triage-decide button.cooldown { + opacity: 0.35; + pointer-events: none; +} + +.message-filters { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.4rem 0.75rem; + border-bottom: 1px solid var(--border); + background: var(--panel-2); + flex-wrap: wrap; + min-height: 44px; + flex-shrink: 0; +} + +.messages-wrap { + flex: 1; + min-height: 0; + position: relative; + display: flex; + flex-direction: column; +} + +.messages-wrap.loading::after { + content: "Loading…"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(12, 14, 20, 0.72); + color: var(--muted); + z-index: 2; +} + +.message-filters input[type="search"] { + flex: 1; + min-width: 160px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 0.35rem 0.55rem; + font-size: 0.85rem; +} + +.filter-count { + color: var(--muted); + font-size: 0.75rem; + white-space: nowrap; +} + +.messages { + overflow: auto; + flex: 1; + padding: 0.5rem 0.75rem 1rem; +} + +.message { + display: flex; + gap: 0.5rem; + align-items: flex-start; + padding: 0.45rem 0.35rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 6px; + content-visibility: auto; + contain-intrinsic-size: 0 56px; + cursor: pointer; +} + +.message:hover { background: rgba(255, 255, 255, 0.03); } + +.message.thread-reply { + margin-left: 1.75rem; + border-left: 2px solid var(--border); + padding-left: 0.65rem; +} + +.message.selected { background: var(--highlight); } + +.message.focused { + outline: 2px solid var(--accent); + outline-offset: 1px; + background: rgba(91, 141, 239, 0.12); +} + +.msg-nav { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; +} + +.btn-msg-nav { + width: 32px; + min-width: 32px; + padding: 0.35rem 0; + font-weight: 700; +} + +.msg-nav-status { + font-size: 0.75rem; + color: var(--muted); + min-width: 5.5rem; + white-space: nowrap; +} + +.message.sel-import { box-shadow: inset 3px 0 0 var(--import); } +.message.sel-skip { opacity: 0.45; } +.message.sel-merge { box-shadow: inset 3px 0 0 var(--accent); } + +.msg-check { + flex-shrink: 0; + padding-top: 0.2rem; +} + +.msg-check input { + width: 1rem; + height: 1rem; + cursor: pointer; +} + +.msg-body { flex: 1; min-width: 0; } + +.msg-badges { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + margin-top: 0.2rem; +} + +.msg-badge { + font-size: 0.65rem; + padding: 0.1rem 0.35rem; + border-radius: 4px; + background: var(--panel-2); + color: var(--muted); +} + +.msg-badge.merge { color: var(--accent); } +.msg-badge.thread { color: var(--warn, #f0b429); } + +.message.hidden-by-filter { display: none; } + +.message.mark { background: var(--highlight); border-radius: 6px; padding: 0.45rem 0.35rem; } + +.message .head { + display: flex; + gap: 0.5rem; + align-items: baseline; +} + +.message .author { font-weight: 600; font-size: 0.88rem; } +.message .time { color: var(--muted); font-size: 0.72rem; } +.message .text { + white-space: pre-wrap; + line-height: 1.4; + font-size: 0.9rem; + margin-top: 0.15rem; +} + +.message.bot .author { color: var(--muted); } +.message.system .text { color: var(--muted); font-style: italic; font-size: 0.82rem; } + +.message mark { + background: var(--highlight); + color: inherit; + padding: 0 2px; + border-radius: 2px; +} + +.msg-attachments { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.35rem; +} + +.file-preview { + padding: 0; + border: none; + background: none; + cursor: zoom-in; + border-radius: 6px; + line-height: 0; +} + +.file-preview:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +img.thumb { + width: 120px; + max-height: 160px; + height: auto; + object-fit: cover; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--panel-2); + transition: transform 0.12s ease, border-color 0.12s ease; +} + +.file-preview:hover img.thumb { + transform: scale(1.02); + border-color: var(--accent); +} + +.file-chip { + display: inline-flex; + align-items: center; + gap: 0.45rem; + max-width: min(100%, 320px); + padding: 0.35rem 0.55rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + text-decoration: none; + font-size: 0.8rem; + cursor: pointer; + font-family: inherit; + text-align: left; +} + +.file-chip:hover { + border-color: var(--accent); + background: rgba(91, 141, 239, 0.12); +} + +.file-chip.missing { + color: var(--muted); + cursor: default; + border-style: dashed; +} + +.file-ext { + flex-shrink: 0; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--accent); + background: rgba(91, 141, 239, 0.15); + padding: 0.15rem 0.35rem; + border-radius: 4px; +} + +.file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-cloud { + flex-shrink: 0; + font-size: 0.7rem; + opacity: 0.7; +} + +.file-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin: 0.25rem 0; +} + +.file-open-link { + flex-shrink: 0; + font-size: 0.72rem; + color: var(--accent); + background: none; + border: none; + padding: 0; + cursor: pointer; + font-family: inherit; +} + +.file-open-link:hover { text-decoration: underline; } + +body.preview-open { overflow: hidden; } + +.preview-modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.preview-modal.hidden { display: none; } + +.preview-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.88); + cursor: zoom-out; +} + +.preview-panel { + position: relative; + z-index: 1; + width: min(96vw, 900px); + max-height: 92vh; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem 1.25rem 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.45); +} + +.preview-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + border: none; + background: var(--panel-2); + color: var(--text); + width: 2rem; + height: 2rem; + border-radius: 50%; + font-size: 1.25rem; + cursor: pointer; + line-height: 1; + z-index: 2; +} + +.preview-loading, +.preview-error { + padding: 2rem 1rem; + text-align: center; + color: var(--muted); +} + +.preview-error { color: var(--skip); } + +.preview-image-wrap { + display: flex; + justify-content: center; + max-height: calc(92vh - 6rem); + overflow: auto; +} + +.preview-image { + max-width: 100%; + max-height: calc(92vh - 6rem); + object-fit: contain; + border-radius: 8px; +} + +.preview-doc-wrap { + flex: 1; + min-height: 0; + max-height: calc(92vh - 7rem); + overflow: hidden; +} + +.preview-doc-scroll { + max-height: calc(92vh - 7rem); + overflow: auto; + padding: 0.5rem 0.75rem 1rem; + background: #fff; + color: #1a1a1a; + border-radius: 8px; + font-size: 0.92rem; + line-height: 1.45; +} + +.preview-doc-scroll h1 { + font-size: 1.35rem; + margin: 0 0 0.75rem; +} + +.preview-doc-scroll h2 { font-size: 1.1rem; } + +.preview-doc-scroll ul, +.preview-doc-scroll ol { + margin: 0.35rem 0 0.75rem 1.25rem; + padding: 0; +} + +.preview-doc-scroll li { margin: 0.2rem 0; } + +.preview-doc-scroll p { margin: 0.5rem 0; } + +.preview-doc-scroll .text-preview { + white-space: pre-wrap; + word-break: break-word; + margin: 0; + font-size: 0.85rem; + font-family: ui-monospace, monospace; +} + +.preview-warn { + margin-top: 0.75rem; + font-size: 0.75rem; + color: #666; +} + +.preview-caption { + color: var(--muted); + font-size: 0.85rem; + padding-right: 2rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-download { + color: var(--accent); + font-size: 0.8rem; + align-self: flex-start; +} + +.date-divider { + text-align: center; + color: var(--muted); + font-size: 0.72rem; + padding: 0.75rem 0 0.35rem; + position: sticky; + top: 0; + background: linear-gradient(var(--bg) 70%, transparent); +} + +.empty { + color: var(--muted); + padding: 2rem; + text-align: center; +} + +.shortcuts-hint { + padding: 0.25rem 0.75rem; + font-size: 0.7rem; + color: var(--muted); + border-top: 1px solid var(--border); + background: var(--panel); +} + +kbd { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.05rem 0.3rem; + font-size: 0.68rem; +} + +.form-block { + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.form-block label { + display: block; + font-size: 0.72rem; + color: var(--muted); + margin-bottom: 0.25rem; +} + +.form-block input, +.form-block textarea { + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 0.4rem 0.55rem; + font-size: 0.85rem; +} + +.save-status { + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + color: var(--muted); +} + +.save-status.ok { color: var(--import); } + +.selection-summary { + padding: 0.65rem 0.75rem; + font-size: 0.8rem; + border-bottom: 1px solid var(--border); +} + +.selection-summary .hint { color: var(--muted); margin: 0; font-size: 0.78rem; } + +.selection-summary .counts { + margin: 0.35rem 0 0; + line-height: 1.5; +} + +.hidden { display: none !important; } + +.panel-divider { + border: none; + border-top: 1px solid var(--border); + margin: 0; +} + +#message-detail select, +#message-detail input, +#message-detail textarea { + width: 100%; + margin-bottom: 0.5rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 0.4rem 0.55rem; + font-size: 0.85rem; +} + +.msg-preview { + font-size: 0.82rem; + color: var(--muted); + margin-bottom: 0.5rem; + max-height: 4.5rem; + overflow: hidden; +} + +.file-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78rem; + margin: 0.25rem 0; +} + +#btn-thread-all { + width: 100%; + margin-top: 0.35rem; + font-size: 0.8rem; +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..1315d67 --- /dev/null +++ b/static/app.js @@ -0,0 +1,1377 @@ +/* global fetch */ + +const SYSTEM_SUBTYPES = new Set([ + "channel_join", + "channel_leave", + "channel_topic", + "channel_purpose", + "channel_name", + "channel_archive", + "channel_unarchive", + "group_join", + "group_leave", +]); + +const state = { + tab: "channels", + channels: [], + users: [], + selectedChannel: null, + selectedUser: null, + selectedMessageId: null, + focusMessageId: null, + channelManifest: {}, + userManifest: {}, + globalNotes: "", + allMessages: [], + messageCache: {}, + saveTimer: null, + globalSearchTimer: null, + navCooldown: false, +}; + +const els = { + stats: document.getElementById("stats"), + globalSearch: document.getElementById("global-search"), + globalSearchResults: document.getElementById("global-search-results"), + sidebarSearch: document.getElementById("sidebar-search"), + filterStatus: document.getElementById("filter-status"), + filterHideArchived: document.getElementById("filter-hide-archived"), + reviewProgress: document.getElementById("review-progress"), + sidebarList: document.getElementById("sidebar-list"), + channelTitle: document.getElementById("channel-title"), + channelMeta: document.getElementById("channel-meta"), + importModePick: document.getElementById("import-mode-pick"), + messageSearch: document.getElementById("message-search"), + filterHideBots: document.getElementById("filter-hide-bots"), + filterHideSystem: document.getElementById("filter-hide-system"), + filterOnlySelected: document.getElementById("filter-only-selected"), + filterCount: document.getElementById("filter-count"), + messagesWrap: document.querySelector(".messages-wrap"), + messages: document.getElementById("messages"), + selectionSummary: document.getElementById("selection-summary"), + messageDetail: document.getElementById("message-detail"), + msgPreview: document.getElementById("msg-preview"), + msgAction: document.getElementById("msg-action"), + msgMergeTarget: document.getElementById("msg-merge-target"), + msgNotes: document.getElementById("msg-notes"), + fileToggles: document.getElementById("file-toggles"), + btnThreadAll: document.getElementById("btn-thread-all"), + mattermostChannel: document.getElementById("mattermost-channel"), + mattermostTeam: document.getElementById("mattermost-team"), + itemNotes: document.getElementById("item-notes"), + globalNotes: document.getElementById("global-notes"), + saveStatus: document.getElementById("save-status"), + btnSave: document.getElementById("btn-save"), + btnImport: document.getElementById("btn-import"), + btnSkip: document.getElementById("btn-skip"), + btnNext: document.getElementById("btn-next"), + btnPrev: document.getElementById("btn-prev"), + btnMsgNext: document.getElementById("btn-msg-next"), + btnMsgPrev: document.getElementById("btn-msg-prev"), + msgNavStatus: document.getElementById("msg-nav-status"), +}; + +async function api(path, options = {}) { + const res = await fetch(path, options); + if (!res.ok) { + const text = await res.text(); + const brief = + text.length > 200 || text.includes("", ">"); +} + +function highlightHtml(text, query) { + const safe = escapeHtml(text); + if (!query) return safe; + const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"); + return safe.replace(re, "$1"); +} + +function fileUploadUrl(file) { + if (!file?.local_relpath) return null; + return `/uploads/${encodeURI(file.local_relpath)}`; +} + +function isImageFile(file) { + const mt = (file.mimetype || "").toLowerCase(); + if (mt.startsWith("image/")) return true; + const ext = (file.name || "").split(".").pop()?.toLowerCase() || ""; + return ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "heic"].includes(ext); +} + +function fileExtLabel(name) { + const ext = (name || "").split(".").pop()?.toUpperCase() || ""; + if (!ext || ext.length > 6) return "FILE"; + return ext; +} + +function isZipBuffer(bytes) { + return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b; +} + +function looksLikeHtml(text) { + const t = text.trim().slice(0, 800).toLowerCase(); + return ( + t.includes("quip-canvas-content") || + t.startsWith(" { + const url = fileUploadUrl(f); + const dl = fileDownloadUrl(f); + const name = escapeHtml(f.name || "file"); + const fid = f.id ? escapeHtml(f.id) : ""; + const previewAttrs = fid + ? `data-preview-file-id="${fid}"` + : url + ? `data-preview-url="${escapeHtml(url)}"` + : ""; + if (isImageFile(f) && (url || fid)) { + const imgSrc = f.local_exists && url ? escapeHtml(url) : dl; + return ``; + } + if (fid || url) { + const cloud = f.local_exists === false ? '' : ""; + return ``; + } + return `${name}`; + }); + return `
${items.join("")}
`; +} + +function ensurePreviewModal() { + let modal = document.getElementById("preview-modal"); + if (modal) return modal; + modal = document.createElement("div"); + modal.id = "preview-modal"; + modal.className = "preview-modal hidden"; + modal.innerHTML = ` +
+
+ + + + + +
+ Download original +
+ `; + document.body.appendChild(modal); + modal.addEventListener("click", (e) => { + if (e.target.closest("[data-close]")) closePreview(); + }); + return modal; +} + +function setPreviewMode(modal, mode) { + modal.querySelector(".preview-loading").classList.toggle("hidden", mode !== "loading"); + modal.querySelector(".preview-error").classList.toggle("hidden", mode !== "error"); + modal.querySelector(".preview-image-wrap").classList.toggle("hidden", mode !== "image"); + modal.querySelector(".preview-doc-wrap").classList.toggle("hidden", mode !== "doc"); +} + +function previewRelFromUploadUrl(url) { + try { + const u = new URL(url, window.location.origin); + return decodeURIComponent(u.pathname.replace(/^\/uploads\//, "")); + } catch { + return null; + } +} + +async function fetchServerPreview({ fileId, rel }) { + const q = fileId + ? `file_id=${encodeURIComponent(fileId)}` + : `rel=${encodeURIComponent(rel)}`; + const data = await api(`/api/file-preview?${q}`); + if (!data.ok) throw new Error(data.error || "Preview failed"); + return data.html || ""; +} + +async function renderDocPreview(url, name, fileId) { + const modal = ensurePreviewModal(); + setPreviewMode(modal, "loading"); + modal.querySelector(".preview-error").textContent = ""; + modal.querySelector(".preview-doc-scroll").innerHTML = ""; + + if (fileId) { + try { + const serverHtml = await fetchServerPreview({ fileId }); + modal.querySelector(".preview-doc-scroll").innerHTML = serverHtml; + return; + } catch { + url = `/api/file-download?file_id=${encodeURIComponent(fileId)}`; + } + } + + const rel = previewRelFromUploadUrl(url); + if (rel) { + try { + const serverHtml = await fetchServerPreview({ rel }); + modal.querySelector(".preview-doc-scroll").innerHTML = serverHtml; + return; + } catch { + /* fall through to direct file fetch */ + } + } + + const res = await fetch(url); + if (!res.ok) throw new Error(`Could not load file (${res.status})`); + const buf = await res.arrayBuffer(); + const bytes = new Uint8Array(buf); + + if (isZipBuffer(bytes)) { + if (typeof mammoth !== "undefined") { + const result = await mammoth.convertToHtml({ arrayBuffer: buf }); + const docHtml = result.value || "

Empty document

"; + const warnings = + result.messages?.length + ? `

${escapeHtml( + result.messages.map((m) => m.message).join("; ") + )}

` + : ""; + modal.querySelector(".preview-doc-scroll").innerHTML = + `
${docHtml}
${warnings}`; + return; + } + if (rel) { + const serverHtml = await fetchServerPreview({ rel }); + modal.querySelector(".preview-doc-scroll").innerHTML = serverHtml; + return; + } + throw new Error("DOCX preview unavailable."); + } + + const text = new TextDecoder("utf-8", { fatal: false }).decode(buf); + if (looksLikeHtml(text)) { + modal.querySelector(".preview-doc-scroll").innerHTML = extractPreviewHtml(text); + return; + } + + const trimmed = text.trim(); + if (trimmed && /^[\x09\x0a\x0d\x20-\x7e\u00a0-\ufffd]*$/.test(trimmed.slice(0, 2000))) { + modal.querySelector(".preview-doc-scroll").innerHTML = `
${escapeHtml(
+      text
+    )}
`; + return; + } + + throw new Error("Binary file — use Download original."); +} + +function openFilePreview(url, name, { asImage = false, fileId = "", downloadUrl = "" } = {}) { + const modal = ensurePreviewModal(); + modal.querySelector(".preview-caption").textContent = name || ""; + modal.querySelector(".preview-download").href = + downloadUrl || (fileId ? `/api/file-download?file_id=${encodeURIComponent(fileId)}` : url); + modal.classList.remove("hidden"); + document.body.classList.add("preview-open"); + + if (asImage) { + setPreviewMode(modal, "image"); + const img = modal.querySelector(".preview-image"); + const imgUrl = + downloadUrl || + (fileId ? `/api/file-download?file_id=${encodeURIComponent(fileId)}` : url); + img.src = imgUrl; + img.alt = name || ""; + return; + } + + renderDocPreview(url, name, fileId) + .then(() => setPreviewMode(modal, "doc")) + .catch((err) => { + setPreviewMode(modal, "error"); + modal.querySelector(".preview-error").textContent = err.message || String(err); + }); +} + +function closePreview() { + const modal = document.getElementById("preview-modal"); + if (!modal) return; + modal.classList.add("hidden"); + modal.querySelector(".preview-image").src = ""; + modal.querySelector(".preview-doc-scroll").innerHTML = ""; + document.body.classList.remove("preview-open"); +} + +function channelEntry(name) { + if (!state.channelManifest[name]) { + state.channelManifest[name] = { + import: false, + status: "undecided", + reviewed: false, + import_mode: "all", + notes: "", + mattermost_channel: "", + mattermost_team: "", + messages: {}, + }; + } + if (!state.channelManifest[name].messages) { + state.channelManifest[name].messages = {}; + } + return state.channelManifest[name]; +} + +function msgEntry(channelName, msgId) { + const ch = channelEntry(channelName); + if (!ch.messages[msgId]) { + ch.messages[msgId] = { action: "inherit", merge_target: "", notes: "", files: {} }; + } + if (!ch.messages[msgId].files) ch.messages[msgId].files = {}; + return ch.messages[msgId]; +} + +function userEntry(id) { + if (!state.userManifest[id]) { + state.userManifest[id] = { import: false, notes: "", mattermost_username: "" }; + } + return state.userManifest[id]; +} + +function effectiveMsgAction(channelName, msg) { + const ch = channelEntry(channelName); + const sel = ch.messages[msg.id]; + const explicit = sel?.action; + if (explicit && explicit !== "inherit") return explicit; + + if (ch.import_mode === "pick") { + return sel?.import === true || sel?.checked ? "import" : "skip"; + } + if (ch.status === "import") return "import"; + if (ch.status === "skip") return "skip"; + return "skip"; +} + +function isMsgSelected(channelName, msg) { + const action = effectiveMsgAction(channelName, msg); + return action === "import" || action === "merge"; +} + +function setNavCooldown(ms = 550) { + state.navCooldown = true; + els.btnImport.classList.add("cooldown"); + els.btnSkip.classList.add("cooldown"); + setTimeout(() => { + state.navCooldown = false; + els.btnImport.classList.remove("cooldown"); + els.btnSkip.classList.remove("cooldown"); + updateTriageBar(); + }, ms); +} + +function setDecideButtonsEnabled(on) { + els.btnImport.disabled = !on || state.navCooldown; + els.btnSkip.disabled = !on || state.navCooldown; +} + +function reviewedCount() { + return state.channels.filter((c) => channelEntry(c.name).reviewed).length; +} + +function visibleChannelList() { + const q = els.sidebarSearch.value.trim().toLowerCase(); + const statusFilter = els.filterStatus.value; + const hideArchived = els.filterHideArchived.checked; + + return state.channels.filter((ch) => { + const entry = channelEntry(ch.name); + const status = entry.status === "partial" ? "partial" : entry.status; + if (hideArchived && ch.is_archived) return false; + if (statusFilter !== "all" && status !== statusFilter) return false; + if (!q) return true; + return ( + ch.name.toLowerCase().includes(q) || + (ch.purpose || "").toLowerCase().includes(q) + ); + }); +} + +function channelIndex(name) { + return visibleChannelList().findIndex((c) => c.name === name); +} + +function countSelections(channelName) { + const ch = channelEntry(channelName); + let imp = 0; + let merge = 0; + let skip = 0; + for (const msg of state.allMessages) { + const a = effectiveMsgAction(channelName, msg); + if (a === "import") imp += 1; + else if (a === "merge") merge += 1; + else skip += 1; + } + return { imp, merge, skip }; +} + +function renderProgress() { + const reviewed = reviewedCount(); + const total = state.channels.length; + const imported = state.channels.filter((c) => { + const e = channelEntry(c.name); + return e.status === "import" || e.status === "partial"; + }).length; + els.reviewProgress.innerHTML = ` + ${reviewed}/${total} reviewed · + ${imported} import/pick · + ${total - reviewed} left + `; +} + +function renderStats(summary) { + els.stats.innerHTML = ` + ${formatNumber(summary.channel_count)} ch + ${formatNumber(summary.total_messages)} msgs + ${reviewedCount()} done + `; +} + +function renderSidebar() { + els.sidebarList.innerHTML = ""; + renderProgress(); + + if (state.tab === "channels") { + const items = visibleChannelList(); + if (!items.length) { + els.sidebarList.innerHTML = '
Nothing matches filters.
'; + return; + } + for (const ch of items) { + const entry = channelEntry(ch.name); + const dotStatus = + entry.status === "partial" ? "partial" : entry.status || "undecided"; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = + "list-item" + (state.selectedChannel === ch.name ? " active" : ""); + const mode = + entry.import_mode === "pick" ? " · cherry-pick" : ""; + btn.innerHTML = ` + +
+
#${escapeHtml(ch.name)}
+
${formatNumber(ch.message_count)} msgs${mode}
+
+ `; + btn.addEventListener("click", () => selectChannel(ch.name)); + els.sidebarList.appendChild(btn); + } + return; + } + + const q = els.sidebarSearch.value.trim().toLowerCase(); + for (const user of state.users.filter((u) => { + if (!q) return true; + return ( + (u.name || "").toLowerCase().includes(q) || + (u.real_name || "").toLowerCase().includes(q) + ); + })) { + const entry = userEntry(user.id); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = + "list-item" + (state.selectedUser === user.id ? " active" : ""); + btn.innerHTML = ` + +
+
${escapeHtml(user.real_name || user.name)}
+
@${escapeHtml(user.name || "?")}
+
+ `; + btn.addEventListener("click", () => selectUser(user)); + els.sidebarList.appendChild(btn); + } +} + +function renderSelectionSummary() { + if (!state.selectedChannel) { + els.selectionSummary.innerHTML = + '

Cherry-pick mode: check messages or use the panel to merge individual posts, threads, and files into another channel.

'; + return; + } + const ch = channelEntry(state.selectedChannel); + const { imp, merge, skip } = countSelections(state.selectedChannel); + const mode = + ch.import_mode === "pick" + ? "Cherry-pick ON — only checked/merged messages import." + : "Whole channel — Import/Skip applies to everything unless you cherry-pick."; + els.selectionSummary.innerHTML = ` +

${imp} import · ${merge} merge · ${skip} skip

+

${mode}

+ `; +} + +function syncNotesPanel() { + if (!state.selectedChannel) return; + const entry = channelEntry(state.selectedChannel); + els.mattermostChannel.value = entry.mattermost_channel || ""; + els.mattermostTeam.value = entry.mattermost_team || ""; + els.itemNotes.value = entry.notes || ""; + els.importModePick.checked = entry.import_mode === "pick"; +} + +function persistNotes() { + if (!state.selectedChannel) return; + const entry = channelEntry(state.selectedChannel); + entry.mattermost_channel = els.mattermostChannel.value.trim(); + entry.mattermost_team = els.mattermostTeam.value.trim(); + entry.notes = els.itemNotes.value; + scheduleAutosave(); +} + +function setChannelDecision(name, status) { + const entry = channelEntry(name); + entry.status = status; + entry.import = status === "import" || status === "partial"; + entry.reviewed = true; + if (status === "import" && entry.import_mode === "pick") { + entry.status = "partial"; + } + const ch = state.channels.find((c) => c.name === name); + if (ch) { + ch.status = entry.status; + ch.reviewed = true; + } + renderSidebar(); + renderSelectionSummary(); + updateTriageBar(); + scheduleAutosave(); +} + +function visibleMessages() { + return state.allMessages.filter(messagePassesFilters); +} + +function ensureCherryPick() { + if (!state.selectedChannel) return; + const entry = channelEntry(state.selectedChannel); + entry.import_mode = "pick"; + entry.status = "partial"; + els.importModePick.checked = true; +} + +function setMessageAction(msg, action, { advance = false } = {}) { + if (!state.selectedChannel || !msg) return; + ensureCherryPick(); + const sel = msgEntry(state.selectedChannel, msg.id); + sel.action = action; + sel.checked = action === "import" || action === "merge"; + if (action !== "merge") sel.merge_target = ""; + + renderAllMessages(); + scheduleAutosave(); + + if (advance) { + const vis = visibleMessages(); + const idx = vis.findIndex((m) => m.id === msg.id); + if (idx >= 0 && idx < vis.length - 1) { + focusMessage(vis[idx + 1].id); + } else { + updateMessageNavStatus(); + } + } +} + +function toggleFocusedImport() { + const msg = state.allMessages.find((m) => m.id === state.focusMessageId); + if (!msg || !state.selectedChannel) return; + const sel = msgEntry(state.selectedChannel, msg.id); + const currentlyOn = + sel.action === "import" || sel.action === "merge" || sel.checked; + setMessageAction(msg, currentlyOn ? "skip" : "import", { advance: false }); +} + +function focusMessage(msgId, { scroll = true } = {}) { + if (!msgId) return; + state.focusMessageId = msgId; + state.selectedMessageId = msgId; + + document.querySelectorAll(".message.focused").forEach((el) => { + el.classList.remove("focused"); + }); + const row = els.messages.querySelector( + `[data-id="${msgId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]` + ); + if (row) { + row.classList.add("focused"); + if (scroll) row.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + updateMessageNavStatus(); + + const msg = state.allMessages.find((m) => m.id === msgId); + if (msg) { + els.msgPreview.textContent = `${msg.user_name}: ${(msg.text || "[no text]").slice(0, 120)}`; + els.messageDetail.classList.remove("hidden"); + const sel = msgEntry(state.selectedChannel, msg.id); + els.msgAction.value = + sel.action && sel.action !== "inherit" ? sel.action : "inherit"; + els.msgMergeTarget.value = sel.merge_target || ""; + els.msgMergeTarget.classList.toggle("hidden", els.msgAction.value !== "merge"); + els.msgNotes.value = sel.notes || ""; + } +} + +function updateMessageNavStatus() { + const vis = visibleMessages(); + if (!vis.length) { + els.msgNavStatus.textContent = "—"; + return; + } + const idx = vis.findIndex((m) => m.id === state.focusMessageId); + const pos = idx >= 0 ? idx + 1 : 0; + els.msgNavStatus.textContent = pos ? `${pos} / ${vis.length}` : `— / ${vis.length}`; +} + +function goNextMessage() { + const vis = visibleMessages(); + if (!vis.length) return; + let idx = vis.findIndex((m) => m.id === state.focusMessageId); + if (idx < 0) idx = -1; + const next = vis[Math.min(idx + 1, vis.length - 1)]; + focusMessage(next.id); +} + +function goPrevMessage() { + const vis = visibleMessages(); + if (!vis.length) return; + let idx = vis.findIndex((m) => m.id === state.focusMessageId); + if (idx < 0) idx = vis.length; + const prev = vis[Math.max(idx - 1, 0)]; + focusMessage(prev.id); +} + +function focusFirstMessage() { + const vis = visibleMessages(); + if (vis.length) focusMessage(vis[0].id, { scroll: false }); + else state.focusMessageId = null; +} + +function messagePassesFilters(msg) { + if (els.filterHideBots.checked && msg.is_bot) return false; + if (els.filterHideSystem.checked) { + if (msg.subtype && SYSTEM_SUBTYPES.has(msg.subtype)) return false; + if (!msg.text && !msg.files?.length) return false; + } + const q = els.messageSearch.value.trim().toLowerCase(); + if (q) { + const hay = `${msg.user_name} ${msg.text}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + if (els.filterOnlySelected.checked && state.selectedChannel) { + if (!isMsgSelected(state.selectedChannel, msg)) return false; + } + return true; +} + +function messageRowHtml(msg, q) { + const ch = state.selectedChannel; + const action = effectiveMsgAction(ch, msg); + const sel = msgEntry(ch, msg.id); + const checked = action === "import" || action === "merge" || sel.checked; + + const classes = ["message"]; + if (msg.is_thread_reply) classes.push("thread-reply"); + if (msg.is_bot) classes.push("bot"); + if (msg.subtype && SYSTEM_SUBTYPES.has(msg.subtype)) classes.push("system"); + if (msg.id === state.selectedMessageId) classes.push("selected"); + if (msg.id === state.focusMessageId) classes.push("focused"); + if (action === "import") classes.push("sel-import"); + if (action === "skip") classes.push("sel-skip"); + if (action === "merge") classes.push("sel-merge"); + + const badges = []; + if (action === "merge" && sel.merge_target) { + badges.push(`→ ${escapeHtml(sel.merge_target)}`); + } + if (msg.is_thread_parent || msg.thread_reply_count > 0) { + badges.push( + `${msg.thread_reply_count || msg.reply_count || 0} replies` + ); + } + if (msg.files?.length) { + badges.push(`${msg.files.length} file(s)`); + } + + const files = renderMessageFiles(msg); + + return ` +
+ +
+
+ ${escapeHtml(msg.user_name)} + ${escapeHtml((msg.time || "").slice(0, 16).replace("T", " "))} +
+
${highlightHtml(msg.text || "", q)}
+ ${files} + ${badges.length ? `
${badges.join("")}
` : ""} +
+
+ `; +} + +function renderAllMessages() { + const q = els.messageSearch.value.trim(); + let shown = 0; + let lastDate = ""; + const parts = []; + + for (const msg of state.allMessages) { + if (!messagePassesFilters(msg)) continue; + shown += 1; + const day = (msg.time || "").slice(0, 10); + if (day && day !== lastDate) { + lastDate = day; + parts.push(`
${escapeHtml(day)}
`); + } + parts.push(messageRowHtml(msg, q)); + } + + if (!state.allMessages.length) { + els.messages.innerHTML = '
No messages.
'; + } else if (!shown) { + els.messages.innerHTML = '
No messages match filters.
'; + } else { + els.messages.innerHTML = parts.join(""); + } + + els.filterCount.textContent = `${shown} / ${state.allMessages.length} shown`; + renderSelectionSummary(); + updateMessageNavStatus(); + + if (state.focusMessageId) { + const row = els.messages.querySelector( + `[data-id="${state.focusMessageId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]` + ); + row?.classList.add("focused"); + } +} + +function openMessageDetail(msg) { + state.selectedMessageId = msg.id; + const ch = state.selectedChannel; + const sel = msgEntry(ch, msg.id); + + els.messageDetail.classList.remove("hidden"); + els.msgPreview.textContent = `${msg.user_name}: ${(msg.text || "[no text]").slice(0, 200)}`; + + els.msgAction.value = + sel.action && sel.action !== "inherit" + ? sel.action + : isMsgSelected(ch, msg) + ? "import" + : "inherit"; + els.msgMergeTarget.value = sel.merge_target || ""; + els.msgMergeTarget.classList.toggle("hidden", els.msgAction.value !== "merge"); + els.msgNotes.value = sel.notes || ""; + + if (msg.is_thread_parent || msg.thread_reply_count > 0) { + els.btnThreadAll.classList.remove("hidden"); + const n = msg.thread_reply_count || msg.reply_count || 0; + els.btnThreadAll.textContent = `Import whole thread (${n} replies)`; + els.btnThreadAll.onclick = () => applyThreadImport(msg, "import"); + } else if (msg.is_thread_reply) { + els.btnThreadAll.classList.remove("hidden"); + els.btnThreadAll.textContent = "Import whole thread"; + els.btnThreadAll.onclick = () => { + const root = state.allMessages.find( + (m) => String(m.ts) === String(msg.thread_root_ts) + ); + if (root) applyThreadImport(root, "import"); + }; + } else { + els.btnThreadAll.classList.add("hidden"); + } + + els.fileToggles.innerHTML = ""; + if (msg.files?.length) { + els.fileToggles.innerHTML = ""; + for (const f of msg.files) { + const fid = f.id || f.name; + const on = sel.files[fid] !== false; + const url = fileUploadUrl(f); + const row = document.createElement("div"); + row.className = "file-toggle-row"; + const openLink = + f.id || url + ? `` + : ""; + row.innerHTML = `${openLink}`; + els.fileToggles.appendChild(row); + } + } + + renderAllMessages(); +} + +function applyThreadImport(rootMsg, action) { + const ch = state.selectedChannel; + const rootTs = String(rootMsg.ts); + for (const msg of state.allMessages) { + if ( + String(msg.ts) === rootTs || + String(msg.thread_root_ts) === rootTs + ) { + const sel = msgEntry(ch, msg.id); + sel.action = action; + sel.checked = action === "import"; + if (action !== "merge") sel.merge_target = ""; + } + } + if (channelEntry(ch).import_mode !== "pick") { + channelEntry(ch).import_mode = "pick"; + els.importModePick.checked = true; + channelEntry(ch).status = "partial"; + } + renderAllMessages(); + scheduleAutosave(); +} + +function applyMessageDetailFromForm() { + if (!state.selectedChannel || !state.selectedMessageId) return; + const sel = msgEntry(state.selectedChannel, state.selectedMessageId); + sel.action = els.msgAction.value; + sel.merge_target = + els.msgAction.value === "merge" ? els.msgMergeTarget.value.trim() : ""; + sel.notes = els.msgNotes.value; + sel.checked = sel.action === "import" || sel.action === "merge"; + + els.fileToggles.querySelectorAll("[data-file-id]").forEach((inp) => { + sel.files[inp.dataset.fileId] = inp.checked; + }); + + const ch = channelEntry(state.selectedChannel); + if (sel.action !== "inherit") { + ch.import_mode = "pick"; + ch.status = "partial"; + ch.reviewed = true; + els.importModePick.checked = true; + } + renderAllMessages(); + scheduleAutosave(); +} + +async function loadChannelMessages(name) { + if (state.messageCache[name]) { + state.allMessages = state.messageCache[name]; + return; + } + els.messagesWrap.classList.add("loading"); + const data = await api( + `/api/channels/${encodeURIComponent(name)}/messages/all` + ); + state.messageCache[name] = data.messages; + state.allMessages = data.messages; + els.messagesWrap.classList.remove("loading"); +} + +function updateTriageBar() { + const ch = state.channels.find((c) => c.name === state.selectedChannel); + if (!ch) return; + const entry = channelEntry(ch.name); + const idx = channelIndex(ch.name); + const list = visibleChannelList(); + const { imp, merge } = countSelections(ch.name); + + els.channelTitle.textContent = `#${ch.name}`; + const statusLabel = + entry.status === "partial" + ? `Cherry-pick (${imp} import, ${merge} merge)` + : entry.status === "import" + ? "Import whole channel" + : entry.status === "skip" + ? "Skip channel" + : "Undecided"; + els.channelMeta.textContent = `${formatNumber(ch.message_count)} msgs · ${statusLabel} · ${idx + 1}/${list.length}`; + + els.btnImport.classList.toggle("active", entry.status === "import"); + els.btnSkip.classList.toggle("active", entry.status === "skip"); + setDecideButtonsEnabled(true); +} + +async function selectChannel(name, { scrollTop = true } = {}) { + persistNotes(); + state.selectedChannel = name; + state.selectedUser = null; + state.selectedMessageId = null; + state.focusMessageId = null; + els.messageDetail.classList.add("hidden"); + + renderSidebar(); + syncNotesPanel(); + setDecideButtonsEnabled(false); + els.channelMeta.textContent = "Loading messages…"; + + try { + await loadChannelMessages(name); + renderAllMessages(); + updateTriageBar(); + if (scrollTop) { + els.messages.scrollTop = 0; + focusFirstMessage(); + } + } catch (err) { + els.messages.innerHTML = `
${escapeHtml(err.message)}
`; + updateTriageBar(); + } +} + +function selectUser(user) { + state.selectedUser = user.id; + state.selectedChannel = null; + state.allMessages = []; + els.channelTitle.textContent = user.real_name || user.name; + els.channelMeta.textContent = `@${user.name}`; + els.messages.innerHTML = '
User mapping only — use Channels for messages.
'; + setDecideButtonsEnabled(false); + renderSidebar(); +} + +function goNextChannel() { + const list = visibleChannelList(); + if (!list.length) return; + const idx = channelIndex(state.selectedChannel); + setNavCooldown(); + selectChannel(list[(idx + 1) % list.length].name); +} + +function goPrevChannel() { + const list = visibleChannelList(); + if (!list.length) return; + const idx = channelIndex(state.selectedChannel); + setNavCooldown(); + selectChannel(list[(idx - 1 + list.length) % list.length].name); +} + +function goNextUndecided() { + const list = visibleChannelList(); + const start = Math.max(0, channelIndex(state.selectedChannel)); + for (let i = 1; i <= list.length; i++) { + const ch = list[(start + i) % list.length]; + if (!channelEntry(ch.name).reviewed) { + setNavCooldown(); + selectChannel(ch.name); + return; + } + } + goNextChannel(); +} + +function importChannel() { + if (!state.selectedChannel || state.navCooldown) return; + const entry = channelEntry(state.selectedChannel); + if (entry.import_mode === "pick") { + setChannelDecision(state.selectedChannel, "partial"); + } else { + setChannelDecision(state.selectedChannel, "import"); + } + persistNotes(); +} + +function skipChannel() { + if (!state.selectedChannel || state.navCooldown) return; + setChannelDecision(state.selectedChannel, "skip"); + persistNotes(); +} + +async function runGlobalSearch(query) { + if (!query.trim()) { + els.globalSearchResults.classList.add("hidden"); + return; + } + const data = await api(`/api/search?q=${encodeURIComponent(query)}&limit=60`); + const parts = []; + if (data.channels?.length) { + parts.push('

Channels

'); + for (const ch of data.channels.slice(0, 12)) { + parts.push( + `` + ); + } + parts.push("
"); + } + if (data.messages?.length) { + parts.push('

Messages

'); + for (const m of data.messages.slice(0, 20)) { + parts.push( + `` + ); + } + parts.push("
"); + } + els.globalSearchResults.innerHTML = + parts.join("") || '
No results
'; + els.globalSearchResults.classList.remove("hidden"); + + els.globalSearchResults.querySelectorAll(".search-hit").forEach((btn) => { + btn.addEventListener("click", async () => { + const ch = btn.dataset.channel; + els.globalSearchResults.classList.add("hidden"); + els.globalSearch.value = ""; + await selectChannel(ch); + const msgId = btn.dataset.msg; + if (msgId) { + const msg = state.allMessages.find((m) => m.id === msgId); + if (msg) { + requestAnimationFrame(() => { + openMessageDetail(msg); + document.querySelector(`[data-id="${msgId}"]`)?.scrollIntoView({ + block: "center", + }); + }); + } + } + }); + }); +} + +function scheduleAutosave() { + clearTimeout(state.saveTimer); + state.saveTimer = setTimeout(() => saveManifest(true), 600); +} + +async function saveManifest(silent = false) { + persistNotes(); + state.globalNotes = els.globalNotes.value; + try { + await api("/api/manifest", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + global_notes: state.globalNotes, + channels: state.channelManifest, + users: state.userManifest, + }), + }); + if (!silent) { + els.saveStatus.textContent = "Saved"; + els.saveStatus.className = "save-status ok"; + setTimeout(() => { + els.saveStatus.textContent = ""; + els.saveStatus.className = "save-status"; + }, 2000); + } + } catch (err) { + els.saveStatus.textContent = err.message; + } +} + +async function loadChannels() { + const data = await api("/api/channels"); + state.channels = data.channels; + for (const ch of state.channels) { + const e = channelEntry(ch.name); + e.import = !!ch.import; + e.status = ch.status || (ch.import ? "import" : "undecided"); + e.reviewed = !!ch.reviewed || ["import", "skip", "partial"].includes(e.status); + e.import_mode = ch.import_mode || "all"; + e.notes = ch.notes || ""; + e.mattermost_channel = ch.mattermost_channel || ""; + e.mattermost_team = ch.mattermost_team || ""; + if (ch.message_selections) e.messages = ch.message_selections; + } +} + +async function loadManifest() { + const manifest = await api("/api/manifest"); + state.channelManifest = manifest.channels || {}; + state.userManifest = manifest.users || {}; + state.globalNotes = manifest.global_notes || ""; + els.globalNotes.value = state.globalNotes; +} + +function onMessageListClick(e) { + if (!state.selectedChannel) return; + + const previewBtn = e.target.closest( + ".image-preview, .file-doc-preview, .file-open-link[data-preview-file-id], .file-open-link[data-preview-url]" + ); + if (previewBtn?.dataset.previewFileId || previewBtn?.dataset.previewUrl) { + e.preventDefault(); + e.stopPropagation(); + const asImage = previewBtn.classList.contains("image-preview"); + openFilePreview(previewBtn.dataset.previewUrl, previewBtn.dataset.previewName, { + asImage, + fileId: previewBtn.dataset.previewFileId || "", + downloadUrl: previewBtn.dataset.previewDownload || "", + }); + return; + } + + const check = e.target.closest("[data-msg-check]"); + if (check) { + e.stopPropagation(); + const row = check.closest(".message"); + const msg = state.allMessages.find((m) => m.id === row?.dataset.id); + if (!msg) return; + const sel = msgEntry(state.selectedChannel, msg.id); + if (check.checked) { + sel.action = "import"; + sel.checked = true; + } else { + sel.action = "skip"; + sel.checked = false; + } + ensureCherryPick(); + renderAllMessages(); + scheduleAutosave(); + focusMessage(msg.id, { scroll: false }); + return; + } + + const row = e.target.closest(".message"); + if (!row) return; + const msg = state.allMessages.find((m) => m.id === row.dataset.id); + if (msg) { + focusMessage(msg.id, { scroll: false }); + openMessageDetail(msg); + } +} + +function bindEvents() { + document.querySelectorAll(".tabs button").forEach((btn) => { + btn.addEventListener("click", () => { + document.querySelectorAll(".tabs button").forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + state.tab = btn.dataset.tab; + renderSidebar(); + }); + }); + + [els.sidebarSearch, els.filterStatus, els.filterHideArchived].forEach((el) => { + el.addEventListener("input", renderSidebar); + el.addEventListener("change", renderSidebar); + }); + + [ + els.messageSearch, + els.filterHideBots, + els.filterHideSystem, + els.filterOnlySelected, + ].forEach((el) => { + el.addEventListener("input", renderAllMessages); + el.addEventListener("change", renderAllMessages); + }); + + els.importModePick.addEventListener("change", () => { + if (!state.selectedChannel) return; + const entry = channelEntry(state.selectedChannel); + entry.import_mode = els.importModePick.checked ? "pick" : "all"; + if (entry.import_mode === "pick") entry.status = "partial"; + renderSelectionSummary(); + updateTriageBar(); + scheduleAutosave(); + }); + + [els.mattermostChannel, els.mattermostTeam, els.itemNotes].forEach((el) => { + el.addEventListener("input", persistNotes); + }); + els.globalNotes.addEventListener("input", scheduleAutosave); + els.btnSave.addEventListener("click", () => saveManifest(false)); + + els.btnImport.addEventListener("click", importChannel); + els.btnSkip.addEventListener("click", skipChannel); + els.btnNext.addEventListener("click", goNextUndecided); + els.btnPrev.addEventListener("click", goPrevChannel); + els.btnMsgNext.addEventListener("click", goNextMessage); + els.btnMsgPrev.addEventListener("click", goPrevMessage); + + els.messages.addEventListener("click", onMessageListClick); + + els.msgAction.addEventListener("change", () => { + els.msgMergeTarget.classList.toggle("hidden", els.msgAction.value !== "merge"); + applyMessageDetailFromForm(); + }); + els.msgMergeTarget.addEventListener("input", applyMessageDetailFromForm); + els.msgNotes.addEventListener("input", applyMessageDetailFromForm); + els.fileToggles.addEventListener("change", applyMessageDetailFromForm); + els.fileToggles.addEventListener("click", (e) => { + const btn = e.target.closest("[data-preview-file-id], [data-preview-url]"); + if (!btn) return; + e.preventDefault(); + e.stopPropagation(); + openFilePreview(btn.dataset.previewUrl, btn.dataset.previewName, { + fileId: btn.dataset.previewFileId || "", + downloadUrl: btn.dataset.previewDownload || "", + }); + }); + + els.globalSearch.addEventListener("input", () => { + clearTimeout(state.globalSearchTimer); + const q = els.globalSearch.value; + if (!q.trim()) { + els.globalSearchResults.classList.add("hidden"); + return; + } + state.globalSearchTimer = setTimeout(() => runGlobalSearch(q), 250); + }); + + document.addEventListener("click", (e) => { + if (!e.target.closest(".global-search-wrap")) { + els.globalSearchResults.classList.add("hidden"); + } + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !document.getElementById("preview-modal")?.classList.contains("hidden")) { + e.preventDefault(); + closePreview(); + return; + } + if (e.target.matches("textarea, input, select") && e.key !== "Escape") return; + + if (e.key === "/") { + e.preventDefault(); + els.globalSearch.focus(); + return; + } + if (!state.selectedChannel) return; + + const msg = state.allMessages.find((m) => m.id === state.focusMessageId); + const shift = e.shiftKey; + + if (e.key === " " && state.focusMessageId) { + e.preventDefault(); + toggleFocusedImport(); + return; + } + + if ((e.key === "i" || e.key === "I") && shift) { + e.preventDefault(); + importChannel(); + return; + } + if ((e.key === "s" || e.key === "S") && shift) { + e.preventDefault(); + skipChannel(); + return; + } + + if (e.key === "i" || e.key === "I") { + e.preventDefault(); + if (msg) setMessageAction(msg, "import", { advance: true }); + else focusFirstMessage(); + return; + } + if (e.key === "s" || e.key === "S") { + e.preventDefault(); + if (msg) setMessageAction(msg, "skip", { advance: true }); + else focusFirstMessage(); + return; + } + if (e.key === "x" || e.key === "X") { + e.preventDefault(); + toggleFocusedImport(); + return; + } + + if (e.key === "ArrowDown" || e.key === "j") { + e.preventDefault(); + goNextMessage(); + return; + } + if (e.key === "ArrowUp" || e.key === "k") { + e.preventDefault(); + goPrevMessage(); + return; + } + if (e.key === "n" || e.key === "N") { + e.preventDefault(); + goNextUndecided(); + return; + } + if (e.key === "Enter" && msg) { + e.preventDefault(); + openMessageDetail(msg); + } + }); +} + +async function init() { + bindEvents(); + try { + const summary = await api("/api/summary"); + await Promise.all([loadManifest(), loadChannels(), api("/api/users").then((d) => { state.users = d.users; })]); + renderStats(summary); + renderSidebar(); + renderSelectionSummary(); + + els.filterStatus.value = "undecided"; + const first = + visibleChannelList()[0] || + state.channels.find((c) => !channelEntry(c.name).reviewed) || + state.channels[0]; + if (first) await selectChannel(first.name); + } catch (err) { + els.messages.innerHTML = `
${escapeHtml(err.message)}
`; + } +} + +init(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..1c122ac --- /dev/null +++ b/static/index.html @@ -0,0 +1,133 @@ + + + + + + Slack → Mattermost import review + + + +
+
+

Slack import review

+
+
+ + +
+
+ +
+
+ + + +
+
+
+ + +
+
+

Select a channel

+   +
+
+ + + +
+
+
+
+ + + +
+ + + + + +
+
+
+
Pick a channel — scroll history, cherry-pick messages, or import/skip the whole channel.
+
+
+
+ J/K prev/next message · Space toggle · I import · S skip · N next channel · ⇧I/⇧S whole channel +
+
+ + +
+ + + +