Initial import: Slack export triage UI for Mattermost migration.

Web UI for channel/message cherry-pick, attachment preview, hydrate script,
and workspace skip presets.
This commit is contained in:
ilia 2026-05-29 13:53:28 -04:00
commit 1ce803fc9c
12 changed files with 3781 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
manifest.json
.index-cache.json
__pycache__/
*.pyc
.DS_Store
.venv/
venv/

15
Makefile Normal file
View File

@ -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

78
README.md Normal file
View File

@ -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).

52
presets/levkin-skips.json Normal file
View File

@ -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"
]
}

14
run.sh Executable file
View File

@ -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"

124
scripts/hydrate-export.sh Executable file
View File

@ -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

130
scripts/preset-skips.py Executable file
View File

@ -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())

458
server.py Executable file
View File

@ -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"<p>{html.escape(line)}</p>")
return "".join(blocks) or "<p><em>Empty document</em></p>"
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"<p>{html.escape(line)}</p>")
body = "".join(blocks) or "<p><em>Empty document</em></p>"
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(("<!doctype", "<html", "<div", "<h1"))
):
return {"ok": True, "kind": "html", "html": text}
if lower_name.endswith((".txt", ".md", ".csv", ".log", ".json")):
return {
"ok": True,
"kind": "text",
"html": f"<pre class='text-preview'>{html.escape(text)}</pre>",
}
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())

506
slack_export.py Normal file
View File

@ -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

887
static/app.css Normal file
View File

@ -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;
}

1377
static/app.js Normal file

File diff suppressed because it is too large Load Diff

133
static/index.html Normal file
View File

@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Slack → Mattermost import review</title>
<link rel="stylesheet" href="/static/app.css" />
</head>
<body>
<div class="app">
<header class="topbar">
<h1>Slack import review</h1>
<div class="stats" id="stats"></div>
<div class="global-search-wrap">
<input
type="search"
id="global-search"
placeholder="Search channels & messages… (press /)"
autocomplete="off"
/>
<div class="search-results hidden" id="global-search-results"></div>
</div>
<div class="actions">
<button type="button" class="primary" id="btn-save">Save</button>
</div>
</header>
<aside class="panel left">
<div class="panel-header filters">
<input type="search" id="sidebar-search" placeholder="Filter list…" />
<div class="filter-row">
<select id="filter-status">
<option value="all">All</option>
<option value="undecided">Undecided</option>
<option value="import">Import</option>
<option value="partial">Cherry-pick</option>
<option value="skip">Skip</option>
</select>
<label class="chk"><input type="checkbox" id="filter-hide-archived" /> Hide archived</label>
</div>
</div>
<div class="progress-bar" id="review-progress"></div>
<div class="tabs">
<button type="button" class="active" data-tab="channels">Channels</button>
<button type="button" data-tab="users">Users</button>
</div>
<div class="list" id="sidebar-list"></div>
</aside>
<main class="center">
<div class="triage-bar">
<div class="triage-nav">
<button type="button" class="btn-nav" id="btn-prev" title="Previous channel">← Prev</button>
<button type="button" class="btn-nav btn-next" id="btn-next" title="Next channel (N)">Next →</button>
</div>
<div class="triage-center">
<h2 id="channel-title">Select a channel</h2>
<span class="triage-meta" id="channel-meta">&nbsp;</span>
</div>
<div class="triage-decide">
<label class="mode-toggle chk" title="When on, only checked messages import">
<input type="checkbox" id="import-mode-pick" />
Cherry-pick messages
</label>
<button type="button" class="btn-skip" id="btn-skip" disabled title="Skip whole channel (Shift+S)">Skip channel</button>
<button type="button" class="btn-import" id="btn-import" disabled title="Import whole channel (Shift+I)">Import channel</button>
</div>
</div>
<div class="message-filters">
<div class="msg-nav">
<button type="button" class="btn-msg-nav" id="btn-msg-prev" title="Previous message (K)"></button>
<button type="button" class="btn-msg-nav" id="btn-msg-next" title="Next message (J)"></button>
<span class="msg-nav-status" id="msg-nav-status"></span>
</div>
<input type="search" id="message-search" placeholder="Filter messages…" />
<label class="chk"><input type="checkbox" id="filter-hide-bots" /> Hide bots</label>
<label class="chk"><input type="checkbox" id="filter-hide-system" checked /> Hide system</label>
<label class="chk"><input type="checkbox" id="filter-only-selected" /> Only selected</label>
<span class="filter-count" id="filter-count"></span>
</div>
<div class="messages-wrap">
<div class="messages" id="messages">
<div class="empty">Pick a channel — scroll history, cherry-pick messages, or import/skip the whole channel.</div>
</div>
</div>
<div class="shortcuts-hint">
<kbd>J</kbd>/<kbd>K</kbd> prev/next message · <kbd>Space</kbd> toggle · <kbd>I</kbd> import · <kbd>S</kbd> skip · <kbd>N</kbd> next channel · <kbd>⇧I</kbd>/<kbd>⇧S</kbd> whole channel
</div>
</main>
<aside class="panel right" id="detail-panel">
<div class="selection-summary" id="selection-summary">
<p class="hint">Click a message to set import / merge / skip for that message, thread, or file.</p>
</div>
<div class="form-block compact hidden" id="message-detail">
<div class="msg-preview" id="msg-preview"></div>
<label>Action</label>
<select id="msg-action">
<option value="inherit">Default (from channel)</option>
<option value="import">Import this</option>
<option value="skip">Skip this</option>
<option value="merge">Merge into channel…</option>
</select>
<input type="text" id="msg-merge-target" class="hidden" placeholder="#target-channel" />
<label>Notes</label>
<textarea id="msg-notes" rows="2" placeholder="Importer note for this message/thread…"></textarea>
<div id="file-toggles"></div>
<button type="button" id="btn-thread-all" class="hidden">Select whole thread</button>
</div>
<hr class="panel-divider" />
<div class="form-block compact">
<label for="mattermost-channel">Mattermost channel</label>
<input type="text" id="mattermost-channel" placeholder="defaults to Slack name" />
</div>
<div class="form-block compact">
<label for="mattermost-team">Mattermost team</label>
<input type="text" id="mattermost-team" placeholder="optional" />
</div>
<div class="form-block">
<label for="item-notes">Channel notes</label>
<textarea id="item-notes" rows="3" placeholder="Channel-level importer instructions…"></textarea>
</div>
<div class="form-block">
<label for="global-notes">Global notes</label>
<textarea id="global-notes" rows="2" placeholder="Whole-import rules…"></textarea>
</div>
<div class="save-status" id="save-status"></div>
</aside>
</div>
<script src="https://cdn.jsdelivr.net/npm/mammoth@1.8.0/mammoth.browser.min.js"></script>
<script src="/static/app.js"></script>
</body>
</html>