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:
commit
1ce803fc9c
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
manifest.json
|
||||
.index-cache.json
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
.venv/
|
||||
venv/
|
||||
15
Makefile
Normal file
15
Makefile
Normal 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
78
README.md
Normal 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
52
presets/levkin-skips.json
Normal 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
14
run.sh
Executable 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
124
scripts/hydrate-export.sh
Executable 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
130
scripts/preset-skips.py
Executable 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
458
server.py
Executable 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
506
slack_export.py
Normal 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
887
static/app.css
Normal 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
1377
static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
133
static/index.html
Normal file
133
static/index.html
Normal 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"> </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>
|
||||
Loading…
x
Reference in New Issue
Block a user