diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..d2f4f7d
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,16 @@
+[run]
+source =
+ slack_export
+ server
+omit =
+ tests/*
+ scripts/*
+
+[report]
+exclude_lines =
+ pragma: no cover
+ if __name__ == .__main__.:
+ raise SystemExit
+ def main
+show_missing = True
+fail_under = 85
diff --git a/.gitignore b/.gitignore
index 37bdf18..2cd51a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,14 @@
manifest.json
+prepared-export/
+node_modules/
+test-results/
+playwright-report/
+playwright/.cache/
.index-cache.json
__pycache__/
*.pyc
.DS_Store
.venv/
venv/
+.coverage
+.pytest_cache/
diff --git a/Makefile b/Makefile
index 2fb28b8..cfe42a6 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: help review hydrate preset-skips
+.PHONY: help review hydrate preset-skips test test-unit test-e2e fixture
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}'
@@ -13,3 +13,36 @@ hydrate: ## Download attachments from official Slack export URLs
preset-skips: ## Apply presets/levkin-skips.json channel skips to manifest.json
@python3 scripts/preset-skips.py
+
+cherry-pick: ## Reset picks and re-run scripts/auto-cherry-pick.py
+ @python3 scripts/auto-cherry-pick.py
+
+prepare-export: ## Build prepared-export/ from manifest.json
+ @python3 scripts/prepare-export.py
+
+fixture: ## Build tests/fixtures/mini-export from your Slack export
+ @python3 scripts/build-test-fixture.py
+
+test: test-unit test-e2e ## Run 100+ pytest + Playwright tests
+
+test-unit: fixture ## Pytest (export, cherry-pick, API)
+ @test -d .venv || python3 -m venv .venv
+ @.venv/bin/pip install -q -r requirements-dev.txt
+ @.venv/bin/python -m pytest tests/ -v --ignore=tests/e2e --cov --cov-report=term-missing:skip-covered
+
+test-cov: test-unit ## Alias with coverage report
+
+test-e2e: fixture ## Playwright UI tests
+ @npm install --no-save 2>/dev/null || npm install
+ @npx playwright install chromium
+ @SLACK_IMPORT_PORT=18765 npm run test:e2e
+
+test-e2e-headed: fixture ## Playwright with visible browser
+ @npm install --no-save 2>/dev/null || npm install
+ @npx playwright install chromium
+ @SLACK_IMPORT_PORT=18765 npx playwright test --headed
+
+test-e2e-ui: fixture ## Playwright interactive UI runner
+ @npm install --no-save 2>/dev/null || npm install
+ @npx playwright install chromium
+ @SLACK_IMPORT_PORT=18765 npx playwright test --ui
diff --git a/README.md b/README.md
index a3f4c17..c4610b7 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ Selections are saved to `manifest.json` (gitignored).
| `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` |
+| `make prepare-export` | Write `prepared-export/` (filtered Slack shape for mmetl) |
## Environment
@@ -58,8 +59,8 @@ Edit `presets/levkin-skips.json` (or copy it) for workspace-specific channels to
## Mattermost import (after review)
-1. Build a filtered export from `manifest.json` (prepare-export — planned).
-2. [mmetl](https://github.com/mattermost/mmetl) `transform slack`
+1. `make prepare-export` — writes `prepared-export/` from `manifest.json` (pytest-covered; not yet run through mmetl/mmctl).
+2. [mmetl](https://github.com/mattermost/mmetl) `transform slack` on `prepared-export/`
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)
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index 7c39d33..7eca7c7 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -2,7 +2,8 @@
**Repo:** https://git.levkin.ca/ilia/slack-sieve (public)
**Clone:** `git clone git@git.levkin.ca:ilia/slack-sieve.git`
-**Last updated:** 2026-05-29
+**Vikunja:** [todo.levkin.ca → Homelab → slack-sieve](https://todo.levkin.ca) (`SLK`)
+**Last updated:** 2026-06-03
---
@@ -17,7 +18,7 @@ Official Slack export (JSON)
→ make hydrate # __uploads/ via xoxe URLs in export
→ make preset-skips # optional noise-channel skips
→ make review # manifest.json (gitignored)
- → prepare-export # TODO — filtered Slack-shaped folder/zip
+ → make prepare-export # prepared-export/ (filtered Slack shape)
→ mmetl transform slack
→ mmctl import
```
@@ -46,6 +47,9 @@ make review # http://127.0.0.1:8765
make hydrate # re-download attachments if needed
make preset-skips # apply presets/levkin-skips.json (safe to re-run; keeps your channel decisions)
+make cherry-pick # bulk data/files picks on priority channels (optional)
+make prepare-export # after review — filtered export for mmetl
+make test # pytest + Playwright
```
---
@@ -119,16 +123,21 @@ Copy and edit for another workspace.
---
-## TODO (next implementer)
+## Open tasks (source of truth)
-1. **`scripts/prepare-export.py`** — read `manifest.json`, emit filtered Slack export dir/zip (`__uploads/` + selected JSON days, thread parents included).
-2. **Pilot import** — one small channel through `mmetl` → `mmctl` on Mattermost VM; verify threads, files, users.
-3. **User mapping** — optional `fetch-emails` or Slack ID → Mattermost username map for `mmetl`.
-4. **Makefile** `make prepare-export` + doc section in README.
+Tracked in **Vikunja → Homelab → slack-sieve**. Update here when scope changes.
+
+| P | Task | Owner | Status |
+|---|------|-------|--------|
+| **P1** | **Channel review in UI** — `make review`, finish 14 undecided + 3 partial ([order](#suggested-review-order)); save manifest | @you | ⏳ **next** |
+| **P1** | Pilot import: one channel via `make prepare-export` → `mmetl` → `mmctl` on MM VM `10.0.10.237` | @agent | ⏳ blocked on review |
+| **P2** | User mapping: Slack ID → Mattermost username for `mmetl` | @agent | backlog |
+| **P3** | Re-hydrate 13 files that returned HTTP 401 (expired export token) | @you | optional |
+| — | `scripts/prepare-export.py` + `make prepare-export` | @agent | ✅ done |
### Mattermost / homelab
-- SSO: Authentik — see ansible `docs/guides/mattermost-authentik-gitlab-oauth.md`
+- SSO: [authentik-apps.md#mattermost](https://git.levkin.ca/ilia/ansible/src/branch/master/docs/guides/authentik-apps.md#mattermost) (ansible)
- Import docs: https://docs.mattermost.com/administration-guide/onboard/migrate-from-slack.html
---
@@ -143,6 +152,10 @@ slack-sieve/
scripts/
hydrate-export.sh
preset-skips.py
+ auto-cherry-pick.py
+ prepare-export.py
+ build-test-fixture.py
+ tests/ # pytest + Playwright (mini-export fixture)
presets/
levkin-skips.json
manifest.json # gitignored — your work in progress
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..17029e3
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,76 @@
+{
+ "name": "slack-sieve",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "slack-sieve",
+ "devDependencies": {
+ "@playwright/test": "^1.49.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
+ "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+ "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5324685
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "slack-sieve",
+ "private": true,
+ "scripts": {
+ "test:e2e": "playwright test",
+ "test:e2e:headed": "playwright test --headed",
+ "test:e2e:ui": "playwright test --ui"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.49.0"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..7f053ee
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,36 @@
+import { defineConfig } from "@playwright/test";
+import path from "path";
+
+const repo = __dirname;
+const port = process.env.SLACK_IMPORT_PORT || "18765";
+
+export default defineConfig({
+ testDir: "tests/e2e",
+ timeout: 30_000,
+ retries: process.env.CI ? 1 : 0,
+ use: {
+ baseURL: `http://127.0.0.1:${port}`,
+ trace: "on-first-retry",
+ },
+ webServer: {
+ command: [
+ "python3",
+ "server.py",
+ "--export",
+ path.join(repo, "tests/fixtures/mini-export"),
+ "--manifest",
+ path.join(repo, "tests/fixtures/test-manifest.json"),
+ "--host",
+ "127.0.0.1",
+ "--port",
+ port,
+ ].join(" "),
+ url: `http://127.0.0.1:${port}/api/health`,
+ reuseExistingServer: !process.env.CI,
+ env: {
+ ...process.env,
+ SLACK_IMPORT_QUIET: "1",
+ SLACK_REVIEW_URL: `http://127.0.0.1:${port}`,
+ },
+ },
+});
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..7722475
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,2 @@
+pytest>=8.0
+pytest-cov>=4.0
diff --git a/scripts/auto-cherry-pick.py b/scripts/auto-cherry-pick.py
new file mode 100644
index 0000000..f4b4128
--- /dev/null
+++ b/scripts/auto-cherry-pick.py
@@ -0,0 +1,439 @@
+#!/usr/bin/env python3
+"""Bulk cherry-pick data-rich messages (files, facts) — not conversation."""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from collections import defaultdict
+from datetime import datetime, timezone
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+MANIFEST_PATH = REPO_ROOT / "manifest.json"
+DEFAULT_EXPORT = Path.home() / "Downloads/Levkin Slack export enriched"
+
+SKIP_SUBTYPES = {
+ "channel_join",
+ "channel_leave",
+ "channel_topic",
+ "channel_purpose",
+ "channel_name",
+ "channel_archive",
+ "channel_unarchive",
+ "group_join",
+ "group_leave",
+}
+
+RETURN_CHANNELS = {"29wilson", "520steeles"}
+
+NOISE_TEXT = re.compile(
+ r"^(ok|okay|thanks|thx|ty|lol|lmao|\+1|done|yes|no|yep|nope|sure|cool|nice|got it|"
+ r"will do|sounds good|perfect|great|👍|👌)\.?!?$",
+ re.IGNORECASE,
+)
+
+# Concrete facts — not decision/planning chatter that merely mentions a topic word.
+DATA_URL = re.compile(r"https?://|www\.", re.IGNORECASE)
+DATA_EMAIL = re.compile(r"\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b", re.IGNORECASE)
+DATA_PHONE = re.compile(
+ r"(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b"
+)
+DATA_MONEY = re.compile(
+ r"\$\s*[\d,]+(?:\.\d{2})?|\b[\d,]+\s*(?:cad|usd|dollars?)\b", re.IGNORECASE
+)
+DATA_DATE = re.compile(
+ r"\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|"
+ r"(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{1,2}(?:,?\s+\d{4})?)\b",
+ re.IGNORECASE,
+)
+DATA_ADDRESS = re.compile(
+ r"\b\d{1,5}\s+\w+(?:\s+\w+){0,3}\s+"
+ r"(?:st|street|ave|avenue|rd|road|dr|drive|blvd|way|crt|court|lane|ln|unit|apt|#)\b",
+ re.IGNORECASE,
+)
+DATA_ACCOUNT = re.compile(
+ r"\b(?:account|policy|invoice|receipt|confirmation|booking|reference|"
+ r"transit|etransfer|sin|passport)\s*#?\s*[:#]?\s*[\w-]{4,}\b",
+ re.IGNORECASE,
+)
+
+CHAT_PATTERNS = re.compile(
+ r"\b(?:should we|what do you think|let me know|any thoughts|sounds good|"
+ r"i think we|we could|maybe we|let's\b|can you\b|do you want|"
+ r"worth it|decided to|going to\b|planning to|wondering if)\b",
+ re.IGNORECASE,
+)
+
+NAME_LIST_TOPIC = re.compile(
+ r"\b(?:baby\s+names?|girl\s+names?|boy\s+names?|name\s+list|names?\s+we\s+like|"
+ r"favorite\s+names?|shortlist|hebrew\s+name|yiddish\s+name|middle\s+name)\b",
+ re.IGNORECASE,
+)
+
+INFORMATIVE_FILE = re.compile(
+ r"invoice|receipt|lease|statement|bill|contract|insurance|tax|form|report|"
+ r"prescription|diagnosis|lab|result|scan|document|agreement|deed|mortgage|"
+ r"hoa|permit|inspection|warranty|quote|estimate|spreadsheet|\.pdf|\.doc|\.xls",
+ re.IGNORECASE,
+)
+
+CASUAL_IMAGE = re.compile(
+ r"^image\.|screenshot|slack-img|photo|img_\d|snap|slack-image|mvimg",
+ re.IGNORECASE,
+)
+
+PROPERTY_CHANNELS = {
+ "284richmond",
+ "45ritson",
+ "122harmony",
+ "6keefer",
+ "153niagara",
+ "banking",
+ "realestate",
+ "airbnb",
+ "york",
+ "viewings",
+}
+
+KIDS_CHANNELS = {
+ "zoey",
+ "izik",
+ "parenting",
+ "moishe",
+ "baby",
+ "after-school-activities",
+}
+
+FAMILY_CHANNELS = {"levkin", "medical", "purchase"}
+
+AUTO_CHANNELS = PROPERTY_CHANNELS | KIDS_CHANNELS | FAMILY_CHANNELS
+
+IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "heic"}
+
+
+def draft_group_key(name: str) -> str:
+ n = name.lower().strip()
+ n = re.sub(r"^draft[\s_\-]+", "", n)
+ n = re.sub(r"[\s_\-]*(final|v\d+|rev\d*|\(\d+\)|copy)", "", n)
+ n = re.sub(r"\.[a-z0-9]{1,5}$", "", n)
+ n = re.sub(r"\s+", " ", n).strip()
+ return n or name.lower()
+
+
+def is_image(file: dict) -> bool:
+ mt = (file.get("mimetype") or "").lower()
+ if mt.startswith("image/"):
+ return True
+ name = (file.get("name") or "").lower()
+ ext = name.rsplit(".", 1)[-1] if "." in name else ""
+ return ext in IMAGE_EXTS
+
+
+def is_doc(file: dict) -> bool:
+ name = (file.get("name") or "").lower()
+ ext = name.rsplit(".", 1)[-1] if "." in name else ""
+ return ext in {"pdf", "doc", "docx", "xls", "xlsx", "csv", "ppt", "pptx"}
+
+
+def is_draft_file(file: dict) -> bool:
+ return "draft" in (file.get("name") or "").lower()
+
+
+_DATA_PATTERNS = (
+ DATA_URL,
+ DATA_EMAIL,
+ DATA_PHONE,
+ DATA_MONEY,
+ DATA_DATE,
+ DATA_ADDRESS,
+ DATA_ACCOUNT,
+)
+
+
+def data_signal_count(text: str) -> int:
+ return sum(1 for p in _DATA_PATTERNS if p.search(text))
+
+
+def has_data_signals(text: str) -> bool:
+ return data_signal_count(text) > 0
+
+
+def text_is_factual(text: str) -> bool:
+ """Require hard facts, not a lone link in chat."""
+ if not text or not text.strip():
+ return False
+ if looks_conversational(text):
+ return False
+ n = data_signal_count(text)
+ if n >= 2:
+ return True
+ if DATA_PHONE.search(text) or DATA_MONEY.search(text) or DATA_EMAIL.search(text):
+ return True
+ if DATA_ADDRESS.search(text) and DATA_DATE.search(text):
+ return True
+ return False
+
+
+def looks_conversational(text: str) -> bool:
+ if not text or NOISE_TEXT.match(text.strip()):
+ return True
+ if CHAT_PATTERNS.search(text):
+ return True
+ if text.count("?") >= 2 and len(text) < 500:
+ return True
+ # Short back-and-forth without hard facts.
+ if len(text) < 80 and not has_data_signals(text):
+ return True
+ return False
+
+
+def is_name_list_message(text: str) -> bool:
+ if not text or not text.strip():
+ return False
+ if NAME_LIST_TOPIC.search(text):
+ return True
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
+ if len(lines) >= 4:
+ name_lines = sum(
+ 1
+ for ln in lines
+ if re.match(r"^[\s•\-\d.]*[A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,})?\s*$", ln)
+ )
+ if name_lines >= 3:
+ return True
+ return False
+
+
+def informative_filename(name: str) -> bool:
+ return bool(INFORMATIVE_FILE.search(name or ""))
+
+
+def informative_image(file: dict, text: str) -> bool:
+ name = file.get("name") or ""
+ if CASUAL_IMAGE.search(name):
+ return text_is_factual(text)
+ if informative_filename(name):
+ return True
+ return text_is_factual(text)
+
+
+def build_latest_draft_winners(export, channel: str) -> tuple[set[str], dict[str, set[str]]]:
+ groups: dict[str, list[tuple[float, str, str]]] = defaultdict(list)
+ for msg in export.iter_channel_messages(channel):
+ for f in msg.get("files") or []:
+ name = f.get("name") or ""
+ if not is_draft_file(f):
+ continue
+ try:
+ ts = float(str(msg.get("ts", "0")).split(".")[0])
+ except (TypeError, ValueError):
+ ts = 0.0
+ fid = f.get("id") or name
+ groups[draft_group_key(name)].append((ts, msg["id"], fid))
+
+ msg_ids: set[str] = set()
+ files_by_msg: dict[str, set[str]] = defaultdict(set)
+ for items in groups.values():
+ _ts, msg_id, fid = max(items, key=lambda x: x[0])
+ msg_ids.add(msg_id)
+ files_by_msg[msg_id].add(fid)
+ return msg_ids, files_by_msg
+
+
+def should_import(
+ msg: dict,
+ channel: str,
+ *,
+ latest_draft_msgs: set[str],
+) -> bool:
+ subtype = msg.get("subtype")
+ text = (msg.get("text") or "").strip()
+ files = msg.get("files") or []
+ msg_id = msg["id"]
+
+ if subtype in SKIP_SUBTYPES and not files:
+ return False
+ if msg.get("is_bot") and not files:
+ return False
+
+ # --- Attachments (primary signal) ---
+ if files:
+ if msg_id in latest_draft_msgs:
+ return True
+ for f in files:
+ if is_draft_file(f):
+ continue
+ if is_doc(f):
+ return True
+ if informative_filename(f.get("name") or ""):
+ return True
+ if is_image(f) and informative_image(f, text):
+ return True
+ return False
+
+ # --- Text without files: facts only, not chat ---
+ if channel in KIDS_CHANNELS and is_name_list_message(text):
+ return True
+
+ return text_is_factual(text)
+
+
+def msg_selection(
+ msg: dict,
+ *,
+ latest_draft_files: set[str] | None = None,
+) -> dict:
+ text = (msg.get("text") or "").strip()
+ files: dict[str, bool] = {}
+ for f in msg.get("files") or []:
+ fid = f.get("id") or f.get("name")
+ if not fid:
+ continue
+ if is_draft_file(f):
+ if latest_draft_files and fid in latest_draft_files:
+ files[fid] = True
+ continue
+ if is_doc(f) or informative_filename(f.get("name") or ""):
+ files[fid] = True
+ elif is_image(f) and informative_image(f, text):
+ files[fid] = True
+ return {
+ "action": "import",
+ "checked": True,
+ "merge_target": "",
+ "notes": "auto-cherry-pick",
+ "files": files,
+ }
+
+
+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 channel_shell() -> dict:
+ return {
+ "import": True,
+ "status": "partial",
+ "reviewed": True,
+ "import_mode": "pick",
+ "notes": "auto-cherry-pick — data/files only",
+ "mattermost_channel": "",
+ "mattermost_team": "",
+ "messages": {},
+ }
+
+
+def reset_all_picks(manifest: dict) -> None:
+ channels = manifest.setdefault("channels", {})
+ for name, entry in channels.items():
+ if not isinstance(entry, dict):
+ continue
+ entry["messages"] = {}
+ if name in RETURN_CHANNELS:
+ channels[name] = channel_entry_skip(
+ "Returned — not importing (29wilson / 520steeles)"
+ )
+ continue
+ if entry.get("status") == "skip":
+ continue
+ entry["import"] = False
+ entry["status"] = "undecided"
+ entry["reviewed"] = False
+ entry["import_mode"] = "all"
+
+
+def main() -> int:
+ dry_run = "--dry-run" in sys.argv
+ reset_only = "--reset" in sys.argv
+ export_path = DEFAULT_EXPORT
+ for arg in sys.argv[1:]:
+ if not arg.startswith("-") and Path(arg).expanduser().is_dir():
+ export_path = Path(arg).expanduser().resolve()
+
+ sys.path.insert(0, str(REPO_ROOT))
+ from slack_export import SlackExport
+
+ export = SlackExport(export_path)
+ manifest: dict = {"channels": {}, "users": {}}
+ if MANIFEST_PATH.is_file():
+ manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
+
+ reset_all_picks(manifest)
+ print("Cleared picks — data/files only mode.")
+ for name in sorted(RETURN_CHANNELS):
+ print(f" #{name} → skip")
+
+ if reset_only:
+ if not dry_run:
+ manifest["updated_at"] = datetime.now(tz=timezone.utc).isoformat()
+ MANIFEST_PATH.write_text(
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
+ encoding="utf-8",
+ )
+ return 0
+
+ channels = manifest.setdefault("channels", {})
+ stats: dict[str, int] = {}
+
+ for ch_name in sorted(AUTO_CHANNELS):
+ if ch_name in RETURN_CHANNELS:
+ continue
+ folder = export.channel_folder(ch_name)
+ if not folder:
+ continue
+ entry = channels.setdefault(ch_name, channel_shell())
+ entry["messages"] = {}
+ if entry.get("status") == "import" and entry.get("import_mode", "all") != "pick":
+ print(f"#{ch_name}: whole-channel import — left unchanged")
+ continue
+ entry["import"] = True
+ entry["status"] = "partial"
+ entry["reviewed"] = True
+ entry["import_mode"] = "pick"
+
+ latest_draft_msgs, draft_files_by_msg = build_latest_draft_winners(export, ch_name)
+ messages = entry.setdefault("messages", {})
+ picked = 0
+ for msg in export.iter_channel_messages(ch_name):
+ if not should_import(msg, ch_name, latest_draft_msgs=latest_draft_msgs):
+ continue
+ messages[msg["id"]] = msg_selection(
+ msg, latest_draft_files=draft_files_by_msg.get(msg["id"])
+ )
+ picked += 1
+ stats[ch_name] = picked
+ print(f"#{ch_name}: {picked} msgs")
+
+ total = sum(stats.values())
+ print(f"\nTotal: {total} messages (data/files only)")
+
+ if dry_run:
+ print("(dry-run — manifest not written)")
+ return 0
+
+ manifest["updated_at"] = datetime.now(tz=timezone.utc).isoformat()
+ note = manifest.get("global_notes") or ""
+ stamp = "auto-cherry-pick v3 — data/files only, no conversation"
+ if stamp not in note:
+ manifest["global_notes"] = (note + "\n" + stamp).strip()
+ MANIFEST_PATH.write_text(
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
+ encoding="utf-8",
+ )
+ print(f"Wrote {MANIFEST_PATH}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/build-test-fixture.py b/scripts/build-test-fixture.py
new file mode 100644
index 0000000..d5ccdb8
--- /dev/null
+++ b/scripts/build-test-fixture.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+"""Copy a small slice of the real Slack export into tests/fixtures/mini-export."""
+
+from __future__ import annotations
+
+import json
+import shutil
+import sys
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+DEFAULT_SRC = Path.home() / "Downloads/Levkin Slack export enriched"
+DST = REPO / "tests" / "fixtures" / "mini-export"
+CHANNELS = ("medical", "zoey", "153niagara")
+MAX_DAY_FILES = 4
+
+
+def main() -> int:
+ src = Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else DEFAULT_SRC
+ if not src.is_dir():
+ print(f"Export not found: {src}", file=sys.stderr)
+ return 1
+
+ if DST.exists():
+ shutil.rmtree(DST)
+ DST.mkdir(parents=True)
+
+ channels = json.loads((src / "channels.json").read_text(encoding="utf-8"))
+ users = json.loads((src / "users.json").read_text(encoding="utf-8"))
+ kept = [c for c in channels if c.get("name") in CHANNELS]
+ (DST / "channels.json").write_text(
+ json.dumps(kept, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
+ )
+ (DST / "users.json").write_text(
+ json.dumps(users[:25], indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
+ )
+
+ total_msgs = 0
+ for name in CHANNELS:
+ sdir = src / name
+ ddir = DST / name
+ if not sdir.is_dir():
+ print(f" skip missing channel #{name}", file=sys.stderr)
+ continue
+ ddir.mkdir()
+ for jf in sorted(sdir.glob("*.json"))[:MAX_DAY_FILES]:
+ shutil.copy2(jf, ddir / jf.name)
+ data = json.loads(jf.read_text(encoding="utf-8"))
+ if isinstance(data, list):
+ total_msgs += len(data)
+
+ print(f"Wrote {DST}")
+ print(f" channels: {', '.join('#' + c['name'] for c in kept)}")
+ print(f" ~{total_msgs} messages (cap {MAX_DAY_FILES} day files per channel)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/prepare-export.py b/scripts/prepare-export.py
new file mode 100644
index 0000000..39dc78d
--- /dev/null
+++ b/scripts/prepare-export.py
@@ -0,0 +1,223 @@
+#!/usr/bin/env python3
+"""Build a filtered Slack-shaped export from manifest.json for mmetl/mmctl."""
+
+from __future__ import annotations
+
+import json
+import shutil
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+MANIFEST_PATH = REPO_ROOT / "manifest.json"
+DEFAULT_EXPORT = Path.home() / "Downloads/Levkin Slack export enriched"
+DEFAULT_OUT = REPO_ROOT / "prepared-export"
+
+
+def message_key(channel: str, ts: str | float) -> str:
+ return f"{channel}:{ts}"
+
+
+def channel_included(entry: dict) -> bool:
+ status = entry.get("status")
+ if status == "skip":
+ return False
+ if status == "import" and entry.get("import_mode", "all") != "pick":
+ return True
+ if status in ("import", "partial"):
+ picks = entry.get("messages") or {}
+ return any(
+ (sel.get("action") in ("import", "merge") or sel.get("checked"))
+ for sel in picks.values()
+ if isinstance(sel, dict)
+ )
+ return False
+
+
+def message_included(entry: dict, key: str) -> bool:
+ status = entry.get("status")
+ if status == "skip":
+ return False
+ if status == "import" and entry.get("import_mode", "all") != "pick":
+ return True
+ picks = entry.get("messages") or {}
+ sel = picks.get(key)
+ if not sel:
+ return False
+ if sel.get("action") == "skip":
+ return False
+ return sel.get("action") in ("import", "merge") or bool(sel.get("checked"))
+
+
+def filter_files_on_message(entry: dict, key: str, raw: dict) -> dict:
+ """Return a copy of the Slack message with only manifest-selected files."""
+ picks = entry.get("messages") or {}
+ sel = picks.get(key) or {}
+ file_flags = sel.get("files") if isinstance(sel.get("files"), dict) else {}
+ files = raw.get("files")
+ if not files:
+ return raw
+ out = dict(raw)
+ kept = []
+ for f in files:
+ if not isinstance(f, dict):
+ continue
+ fid = f.get("id") or f.get("name")
+ if not fid:
+ continue
+ if file_flags and file_flags.get(fid) is False:
+ continue
+ kept.append(f)
+ if not kept:
+ out.pop("files", None)
+ else:
+ out["files"] = kept
+ return out
+
+
+def prepare_export(
+ export_root: Path,
+ manifest: dict,
+ out_root: Path,
+ *,
+ export_mod=None,
+) -> dict[str, int]:
+ """
+ Write filtered export to out_root. Returns per-channel message counts.
+ """
+ sys.path.insert(0, str(REPO_ROOT))
+ from slack_export import SlackExport, load_json
+
+ export = export_mod or SlackExport(export_root)
+ out_root = out_root.resolve()
+ if out_root.exists():
+ shutil.rmtree(out_root)
+ out_root.mkdir(parents=True)
+
+ channels_meta = load_json(export.paths.channels_json)
+ if not isinstance(channels_meta, list):
+ channels_meta = []
+
+ manifest_channels = manifest.get("channels") or {}
+ included_names: set[str] = set()
+ stats: dict[str, int] = {}
+
+ for ch in channels_meta:
+ name = ch.get("name")
+ if not name:
+ continue
+ entry = manifest_channels.get(name) or {}
+ if not channel_included(entry):
+ continue
+ included_names.add(name)
+
+ # channels.json subset
+ filtered_meta = [c for c in channels_meta if c.get("name") in included_names]
+ (out_root / "channels.json").write_text(
+ json.dumps(filtered_meta, indent=2, ensure_ascii=False) + "\n",
+ encoding="utf-8",
+ )
+
+ # users.json — copy whole (mmetl needs user mapping)
+ users_src = export.paths.users_json
+ if users_src.is_file():
+ shutil.copy2(users_src, out_root / "users.json")
+
+ uploads_out = out_root / "__uploads"
+ uploads_src = export.paths.root / "__uploads"
+
+ for name in sorted(included_names):
+ entry = manifest_channels[name]
+ ch_out = out_root / name
+ ch_out.mkdir(parents=True)
+ count = 0
+ for date in export.list_channel_date_files(name):
+ raw_list = export.read_messages_file(name, date)
+ if not isinstance(raw_list, list):
+ continue
+ kept: list[dict] = []
+ for raw in raw_list:
+ if not isinstance(raw, dict):
+ continue
+ ts = raw.get("ts")
+ if ts is None:
+ continue
+ key = message_key(name, ts)
+ if not message_included(entry, key):
+ continue
+ msg = filter_files_on_message(entry, key, raw)
+ kept.append(msg)
+ count += 1
+ for f in msg.get("files") or []:
+ if not isinstance(f, dict):
+ continue
+ fid = f.get("id")
+ fname = f.get("name")
+ if fid and fname and uploads_src.is_dir():
+ src = uploads_src / fid / fname
+ if src.is_file():
+ dest = uploads_out / fid / fname
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(src, dest)
+ if kept:
+ (ch_out / f"{date}.json").write_text(
+ json.dumps(kept, indent=2, ensure_ascii=False) + "\n",
+ encoding="utf-8",
+ )
+ stats[name] = count
+
+ summary = {
+ "channels": len(stats),
+ "messages": sum(stats.values()),
+ "per_channel": stats,
+ }
+ (out_root / "prepare-export-summary.json").write_text(
+ json.dumps(summary, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ return stats
+
+
+def main() -> int:
+ export_path = DEFAULT_EXPORT
+ out_path = DEFAULT_OUT
+ manifest_path = MANIFEST_PATH
+
+ args = sys.argv[1:]
+ i = 0
+ while i < len(args):
+ if args[i] == "--export" and i + 1 < len(args):
+ export_path = Path(args[i + 1]).expanduser()
+ i += 2
+ elif args[i] == "--out" and i + 1 < len(args):
+ out_path = Path(args[i + 1]).expanduser()
+ i += 2
+ elif args[i] == "--manifest" and i + 1 < len(args):
+ manifest_path = Path(args[i + 1]).expanduser()
+ i += 2
+ elif not args[i].startswith("-"):
+ export_path = Path(args[i]).expanduser()
+ i += 1
+ else:
+ i += 1
+
+ export_path = export_path.resolve()
+ if not export_path.is_dir():
+ print(f"Export not found: {export_path}", file=sys.stderr)
+ return 1
+ if not manifest_path.is_file():
+ print(f"Manifest not found: {manifest_path}", file=sys.stderr)
+ return 1
+
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ stats = prepare_export(export_path, manifest, out_path.resolve())
+ total = sum(stats.values())
+ print(f"Wrote {out_path}")
+ print(f" {len(stats)} channels, {total} messages")
+ for name, n in sorted(stats.items()):
+ print(f" #{name}: {n}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/server.py b/server.py
index ed75d3c..58b6984 100755
--- a/server.py
+++ b/server.py
@@ -315,6 +315,16 @@ class ReviewHandler(BaseHTTPRequestHandler):
limit = min(int((qs.get("limit") or ["80"])[0]), 200)
return self._send_json(self.export.search_global(query, limit=limit))
+ if path == "/api/search-files":
+ query = (qs.get("q") or [""])[0]
+ file_type = (qs.get("type") or ["all"])[0]
+ limit = min(int((qs.get("limit") or ["80"])[0]), 200)
+ return self._send_json(
+ self.export.search_files_global(
+ query, file_type=file_type, limit=limit
+ )
+ )
+
if path.startswith("/api/channels/") and path.endswith("/search"):
channel_name = path.split("/")[3]
query = (qs.get("q") or [""])[0]
@@ -386,6 +396,9 @@ class ReviewHandler(BaseHTTPRequestHandler):
saved_messages = entry.get("messages") or entry.get("message_selections")
if not isinstance(saved_messages, dict):
saved_messages = {}
+ prev = current["channels"].get(name) or {}
+ prev_messages = prev.get("messages") if isinstance(prev.get("messages"), dict) else {}
+ merged_messages = {**prev_messages, **saved_messages}
current["channels"][name] = {
"import": status in ("import", "partial"),
"status": status,
@@ -395,7 +408,7 @@ class ReviewHandler(BaseHTTPRequestHandler):
"notes": entry.get("notes") or "",
"mattermost_channel": entry.get("mattermost_channel") or "",
"mattermost_team": entry.get("mattermost_team") or "",
- "messages": saved_messages,
+ "messages": merged_messages,
}
if isinstance(body.get("users"), dict):
@@ -424,9 +437,19 @@ def main() -> int:
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
+ parser.add_argument(
+ "--manifest",
+ default=os.environ.get("SLACK_MANIFEST_PATH"),
+ help="Path to manifest.json (default: repo manifest.json)",
+ )
args = parser.parse_args()
paths = resolve_paths(args.export)
+ manifest_path = (
+ Path(args.manifest).expanduser().resolve()
+ if args.manifest
+ else paths.manifest_json
+ )
try:
export = SlackExport(paths.root)
except FileNotFoundError as exc:
@@ -440,13 +463,13 @@ def main() -> int:
handler_cls = type(
"BoundReviewHandler",
(ReviewHandler,),
- {"export": export, "manifest_path": paths.manifest_json},
+ {"export": export, "manifest_path": manifest_path},
)
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}")
+ print(f"Manifest: {manifest_path}")
try:
server.serve_forever()
except KeyboardInterrupt:
diff --git a/slack_export.py b/slack_export.py
index f8d1c17..7102477 100644
--- a/slack_export.py
+++ b/slack_export.py
@@ -169,6 +169,7 @@ class SlackExport:
def channel_dirs(self) -> list[Path]:
skip = {
+ "__uploads",
"channels.json",
"users.json",
"canvases.json",
@@ -373,6 +374,108 @@ class SlackExport:
"users": users,
}
+ @staticmethod
+ def classify_file(file: dict[str, Any]) -> str:
+ """Rough file bucket for search/filter UI."""
+ mt = (file.get("mimetype") or "").lower()
+ name = (file.get("name") or "").lower()
+ ext = name.rsplit(".", 1)[-1] if "." in name else ""
+
+ if mt.startswith("image/") or ext in {
+ "png",
+ "jpg",
+ "jpeg",
+ "gif",
+ "webp",
+ "bmp",
+ "svg",
+ "heic",
+ }:
+ return "image"
+ if ext == "pdf" or mt == "application/pdf":
+ return "pdf"
+ if ext in {"doc", "docx"} or "word" in mt:
+ return "doc"
+ if ext in {"xls", "xlsx", "csv"} or "spreadsheet" in mt or "excel" in mt:
+ return "spreadsheet"
+ if ext in {"ppt", "pptx"}:
+ return "slides"
+ if ext in {"zip", "rar", "7z", "gz"}:
+ return "archive"
+ if ext in {"mp4", "mov", "webm", "avi"} or mt.startswith("video/"):
+ return "video"
+ if ext in {"mp3", "m4a", "wav"} or mt.startswith("audio/"):
+ return "audio"
+ if ext in {"txt", "md", "log", "json"} or mt.startswith("text/"):
+ return "text"
+ return "other"
+
+ def message_file_kinds(self, msg: dict[str, Any]) -> set[str]:
+ return {self.classify_file(f) for f in msg.get("files") or []}
+
+ def search_files_global(
+ self,
+ query: str = "",
+ *,
+ file_type: str = "all",
+ limit: int = 80,
+ ) -> dict[str, Any]:
+ """Find messages with attachments across all channels."""
+ q = query.strip()
+ pattern = re.compile(re.escape(q), re.IGNORECASE) if q else None
+ type_filter = (file_type or "all").strip().lower()
+ hits: list[dict[str, Any]] = []
+
+ for folder in self.channel_dirs():
+ if len(hits) >= limit:
+ break
+ channel_name = folder.name
+ for date in self.list_channel_date_files(channel_name):
+ if len(hits) >= limit:
+ break
+ for raw in self.read_messages_file(channel_name, date):
+ normalized = self.normalize_message(raw, channel_name, date)
+ files = normalized.get("files") or []
+ if not files:
+ continue
+ kinds = self.message_file_kinds(normalized)
+ if type_filter != "all" and type_filter not in kinds:
+ continue
+ if pattern:
+ hay = " ".join(
+ [
+ normalized.get("text") or "",
+ normalized.get("user_name") or "",
+ channel_name,
+ " ".join(f.get("name") or "" for f in files),
+ ]
+ )
+ if not pattern.search(hay):
+ continue
+ matching = files
+ if type_filter != "all":
+ matching = [
+ f
+ for f in files
+ if self.classify_file(f) == type_filter
+ ]
+ hits.append(
+ {
+ **normalized,
+ "matching_files": matching,
+ "file_kinds": sorted(kinds),
+ }
+ )
+ if len(hits) >= limit:
+ break
+
+ return {
+ "query": q,
+ "file_type": type_filter,
+ "hits": hits,
+ "total": len(hits),
+ }
+
def search_channels(self, query: str) -> list[dict[str, Any]]:
q = query.strip().lower()
summary = self.build_summary()["channels"]
diff --git a/static/app.css b/static/app.css
index 7769626..9422859 100644
--- a/static/app.css
+++ b/static/app.css
@@ -12,11 +12,65 @@
--skip-bg: rgba(224, 108, 117, 0.12);
--undecided: #f0b429;
--highlight: rgba(91, 141, 239, 0.35);
+ --scrollbar-track: #1a2030;
+ --scrollbar-thumb: #5a6a88;
+ --scrollbar-thumb-hover: #7a8aab;
font-family: "SF Pro Text", "Segoe UI", system-ui, sans-serif;
}
* { box-sizing: border-box; }
+/* Scrollbars — visible on dark panels (Firefox + WebKit) */
+.list,
+.messages,
+.search-results,
+.preview-doc-scroll,
+.panel.right,
+.messages-wrap {
+ scrollbar-width: thin;
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
+}
+
+.list::-webkit-scrollbar,
+.messages::-webkit-scrollbar,
+.search-results::-webkit-scrollbar,
+.preview-doc-scroll::-webkit-scrollbar,
+.panel.right::-webkit-scrollbar,
+.messages-wrap::-webkit-scrollbar {
+ width: 11px;
+ height: 11px;
+}
+
+.list::-webkit-scrollbar-track,
+.messages::-webkit-scrollbar-track,
+.search-results::-webkit-scrollbar-track,
+.preview-doc-scroll::-webkit-scrollbar-track,
+.panel.right::-webkit-scrollbar-track,
+.messages-wrap::-webkit-scrollbar-track {
+ background: var(--scrollbar-track);
+ border-radius: 6px;
+}
+
+.list::-webkit-scrollbar-thumb,
+.messages::-webkit-scrollbar-thumb,
+.search-results::-webkit-scrollbar-thumb,
+.preview-doc-scroll::-webkit-scrollbar-thumb,
+.panel.right::-webkit-scrollbar-thumb,
+.messages-wrap::-webkit-scrollbar-thumb {
+ background: var(--scrollbar-thumb);
+ border-radius: 6px;
+ border: 2px solid var(--scrollbar-track);
+}
+
+.list::-webkit-scrollbar-thumb:hover,
+.messages::-webkit-scrollbar-thumb:hover,
+.search-results::-webkit-scrollbar-thumb:hover,
+.preview-doc-scroll::-webkit-scrollbar-thumb:hover,
+.panel.right::-webkit-scrollbar-thumb:hover,
+.messages-wrap::-webkit-scrollbar-thumb:hover {
+ background: var(--scrollbar-thumb-hover);
+}
+
body {
margin: 0;
background: var(--bg);
@@ -62,8 +116,25 @@ header.topbar {
max-width: 480px;
}
-.global-search-wrap input {
- width: 100%;
+.global-search-row {
+ display: flex;
+ gap: 0.35rem;
+ align-items: center;
+}
+
+.global-search-row select {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text);
+ padding: 0.4rem 0.35rem;
+ font-size: 0.78rem;
+ max-width: 5.5rem;
+}
+
+.global-search-row input {
+ flex: 1;
+ min-width: 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
@@ -72,6 +143,34 @@ header.topbar {
font-size: 0.9rem;
}
+.message-filters select {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ padding: 0.3rem 0.4rem;
+ font-size: 0.78rem;
+}
+
+.btn-mini {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 0.72rem;
+ padding: 0.28rem 0.45rem;
+ cursor: pointer;
+}
+
+.btn-mini:hover {
+ border-color: var(--accent);
+}
+
+.btn-mini.active {
+ border-color: var(--import);
+ background: var(--import-bg);
+}
+
.search-results {
position: absolute;
top: calc(100% + 4px);
diff --git a/static/app.js b/static/app.js
index 1315d67..31b6998 100644
--- a/static/app.js
+++ b/static/app.js
@@ -33,6 +33,8 @@ const state = {
const els = {
stats: document.getElementById("stats"),
globalSearch: document.getElementById("global-search"),
+ globalSearchMode: document.getElementById("global-search-mode"),
+ globalFileType: document.getElementById("global-file-type"),
globalSearchResults: document.getElementById("global-search-results"),
sidebarSearch: document.getElementById("sidebar-search"),
filterStatus: document.getElementById("filter-status"),
@@ -43,6 +45,9 @@ const els = {
channelMeta: document.getElementById("channel-meta"),
importModePick: document.getElementById("import-mode-pick"),
messageSearch: document.getElementById("message-search"),
+ filterAttachmentType: document.getElementById("filter-attachment-type"),
+ btnSelectImages: document.getElementById("btn-select-images"),
+ btnSelectFiles: document.getElementById("btn-select-files"),
filterHideBots: document.getElementById("filter-hide-bots"),
filterHideSystem: document.getElementById("filter-hide-system"),
filterOnlySelected: document.getElementById("filter-only-selected"),
@@ -115,6 +120,22 @@ function isImageFile(file) {
return ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "heic"].includes(ext);
}
+function fileKind(file) {
+ const mt = (file.mimetype || "").toLowerCase();
+ const name = (file.name || "").toLowerCase();
+ const ext = name.includes(".") ? name.split(".").pop() : "";
+ if (isImageFile(file)) return "image";
+ if (ext === "pdf" || mt === "application/pdf") return "pdf";
+ if (["doc", "docx"].includes(ext) || mt.includes("word")) return "doc";
+ if (["xls", "xlsx", "csv"].includes(ext) || mt.includes("spreadsheet") || mt.includes("excel"))
+ return "spreadsheet";
+ return "other";
+}
+
+function messageKinds(msg) {
+ return new Set((msg.files || []).map(fileKind));
+}
+
function fileExtLabel(name) {
const ext = (name || "").split(".").pop()?.toUpperCase() || "";
if (!ext || ext.length > 6) return "FILE";
@@ -383,20 +404,45 @@ function userEntry(id) {
return state.userManifest[id];
}
+function channelUsesCherryPick(ch) {
+ return ch.import_mode === "pick" || ch.status === "partial";
+}
+
function effectiveMsgAction(channelName, msg) {
const ch = channelEntry(channelName);
const sel = ch.messages[msg.id];
const explicit = sel?.action;
- if (explicit && explicit !== "inherit") return explicit;
+ if (explicit === "import" || explicit === "merge" || explicit === "skip") {
+ return explicit;
+ }
- if (ch.import_mode === "pick") {
- return sel?.import === true || sel?.checked ? "import" : "skip";
+ if (channelUsesCherryPick(ch)) {
+ return "skip";
}
if (ch.status === "import") return "import";
if (ch.status === "skip") return "skip";
return "skip";
}
+function clearMsgSelection(channelName, msgId) {
+ const ch = channelEntry(channelName);
+ if (ch.messages?.[msgId]) {
+ delete ch.messages[msgId];
+ }
+}
+
+function setMsgImport(channelName, msg, on) {
+ if (!on) {
+ clearMsgSelection(channelName, msg.id);
+ return;
+ }
+ ensureCherryPick();
+ const sel = msgEntry(channelName, msg.id);
+ sel.action = "import";
+ sel.checked = true;
+ sel.merge_target = "";
+}
+
function isMsgSelected(channelName, msg) {
const action = effectiveMsgAction(channelName, msg);
return action === "import" || action === "merge";
@@ -423,6 +469,22 @@ function reviewedCount() {
return state.channels.filter((c) => channelEntry(c.name).reviewed).length;
}
+function channelPickCount(name) {
+ const msgs = channelEntry(name).messages || {};
+ return Object.values(msgs).filter(
+ (m) => m.action === "import" || m.action === "merge"
+ ).length;
+}
+
+function channelHasPicks(name) {
+ const entry = channelEntry(name);
+ return (
+ entry.status === "partial" ||
+ entry.import_mode === "pick" ||
+ channelPickCount(name) > 0
+ );
+}
+
function visibleChannelList() {
const q = els.sidebarSearch.value.trim().toLowerCase();
const statusFilter = els.filterStatus.value;
@@ -432,7 +494,9 @@ function visibleChannelList() {
const entry = channelEntry(ch.name);
const status = entry.status === "partial" ? "partial" : entry.status;
if (hideArchived && ch.is_archived) return false;
- if (statusFilter !== "all" && status !== statusFilter) return false;
+ if (statusFilter === "partial") {
+ if (!channelHasPicks(ch.name)) return false;
+ } else if (statusFilter !== "all" && status !== statusFilter) return false;
if (!q) return true;
return (
ch.name.toLowerCase().includes(q) ||
@@ -499,8 +563,13 @@ function renderSidebar() {
btn.type = "button";
btn.className =
"list-item" + (state.selectedChannel === ch.name ? " active" : "");
+ const picks = channelPickCount(ch.name);
const mode =
- entry.import_mode === "pick" ? " · cherry-pick" : "";
+ entry.import_mode === "pick" || picks > 0
+ ? picks > 0
+ ? ` · ${formatNumber(picks)} picked`
+ : " · cherry-pick"
+ : "";
btn.innerHTML = `
@@ -608,13 +677,19 @@ function ensureCherryPick() {
function setMessageAction(msg, action, { advance = false } = {}) {
if (!state.selectedChannel || !msg) return;
- ensureCherryPick();
- const sel = msgEntry(state.selectedChannel, msg.id);
- sel.action = action;
- sel.checked = action === "import" || action === "merge";
- if (action !== "merge") sel.merge_target = "";
+ if (action === "import" || action === "merge") {
+ ensureCherryPick();
+ const sel = msgEntry(state.selectedChannel, msg.id);
+ sel.action = action;
+ sel.checked = true;
+ if (action !== "merge") sel.merge_target = "";
+ } else {
+ clearMsgSelection(state.selectedChannel, msg.id);
+ }
renderAllMessages();
+ renderSidebar();
+ updateTriageBar();
scheduleAutosave();
if (advance) {
@@ -632,8 +707,7 @@ function toggleFocusedImport() {
const msg = state.allMessages.find((m) => m.id === state.focusMessageId);
if (!msg || !state.selectedChannel) return;
const sel = msgEntry(state.selectedChannel, msg.id);
- const currentlyOn =
- sel.action === "import" || sel.action === "merge" || sel.checked;
+ const currentlyOn = isMsgSelected(state.selectedChannel, msg);
setMessageAction(msg, currentlyOn ? "skip" : "import", { advance: false });
}
@@ -716,6 +790,15 @@ function messagePassesFilters(msg) {
if (els.filterOnlySelected.checked && state.selectedChannel) {
if (!isMsgSelected(state.selectedChannel, msg)) return false;
}
+ const att = els.filterAttachmentType?.value || "all";
+ if (att !== "all") {
+ const kinds = messageKinds(msg);
+ if (att === "any") {
+ if (!msg.files?.length) return false;
+ } else if (!kinds.has(att)) {
+ return false;
+ }
+ }
return true;
}
@@ -723,7 +806,7 @@ function messageRowHtml(msg, q) {
const ch = state.selectedChannel;
const action = effectiveMsgAction(ch, msg);
const sel = msgEntry(ch, msg.id);
- const checked = action === "import" || action === "merge" || sel.checked;
+ const checked = action === "import" || action === "merge";
const classes = ["message"];
if (msg.is_thread_reply) classes.push("thread-reply");
@@ -796,6 +879,7 @@ function renderAllMessages() {
els.filterCount.textContent = `${shown} / ${state.allMessages.length} shown`;
renderSelectionSummary();
updateMessageNavStatus();
+ updateBulkSelectButtons();
if (state.focusMessageId) {
const row = els.messages.querySelector(
@@ -808,20 +892,21 @@ function renderAllMessages() {
function openMessageDetail(msg) {
state.selectedMessageId = msg.id;
const ch = state.selectedChannel;
- const sel = msgEntry(ch, msg.id);
+ const saved = channelEntry(ch).messages[msg.id];
+ const isOn = isMsgSelected(ch, msg);
els.messageDetail.classList.remove("hidden");
els.msgPreview.textContent = `${msg.user_name}: ${(msg.text || "[no text]").slice(0, 200)}`;
els.msgAction.value =
- sel.action && sel.action !== "inherit"
- ? sel.action
- : isMsgSelected(ch, msg)
+ saved?.action && saved.action !== "inherit"
+ ? saved.action
+ : isOn
? "import"
: "inherit";
- els.msgMergeTarget.value = sel.merge_target || "";
+ els.msgMergeTarget.value = saved?.merge_target || "";
els.msgMergeTarget.classList.toggle("hidden", els.msgAction.value !== "merge");
- els.msgNotes.value = sel.notes || "";
+ els.msgNotes.value = saved?.notes || "";
if (msg.is_thread_parent || msg.thread_reply_count > 0) {
els.btnThreadAll.classList.remove("hidden");
@@ -846,7 +931,7 @@ function openMessageDetail(msg) {
els.fileToggles.innerHTML = "
";
for (const f of msg.files) {
const fid = f.id || f.name;
- const on = sel.files[fid] !== false;
+ const on = isOn && saved?.files?.[fid] !== false;
const url = fileUploadUrl(f);
const row = document.createElement("div");
row.className = "file-toggle-row";
@@ -876,10 +961,14 @@ function applyThreadImport(rootMsg, action) {
String(msg.ts) === rootTs ||
String(msg.thread_root_ts) === rootTs
) {
- const sel = msgEntry(ch, msg.id);
- sel.action = action;
- sel.checked = action === "import";
- if (action !== "merge") sel.merge_target = "";
+ if (action === "import" || action === "merge") {
+ const sel = msgEntry(ch, msg.id);
+ sel.action = action;
+ sel.checked = true;
+ if (action !== "merge") sel.merge_target = "";
+ } else {
+ clearMsgSelection(ch, msg.id);
+ }
}
}
if (channelEntry(ch).import_mode !== "pick") {
@@ -893,25 +982,28 @@ function applyThreadImport(rootMsg, action) {
function applyMessageDetailFromForm() {
if (!state.selectedChannel || !state.selectedMessageId) return;
- const sel = msgEntry(state.selectedChannel, state.selectedMessageId);
- sel.action = els.msgAction.value;
- sel.merge_target =
- els.msgAction.value === "merge" ? els.msgMergeTarget.value.trim() : "";
- sel.notes = els.msgNotes.value;
- sel.checked = sel.action === "import" || sel.action === "merge";
+ const action = els.msgAction.value;
+ const msg = state.allMessages.find((m) => m.id === state.selectedMessageId);
+ if (!msg) return;
- els.fileToggles.querySelectorAll("[data-file-id]").forEach((inp) => {
- sel.files[inp.dataset.fileId] = inp.checked;
- });
-
- const ch = channelEntry(state.selectedChannel);
- if (sel.action !== "inherit") {
- ch.import_mode = "pick";
- ch.status = "partial";
- ch.reviewed = true;
- els.importModePick.checked = true;
+ if (action === "skip" || action === "inherit") {
+ clearMsgSelection(state.selectedChannel, state.selectedMessageId);
+ } else {
+ ensureCherryPick();
+ const sel = msgEntry(state.selectedChannel, state.selectedMessageId);
+ sel.action = action;
+ sel.checked = action === "import" || action === "merge";
+ sel.merge_target = action === "merge" ? els.msgMergeTarget.value.trim() : "";
+ sel.notes = els.msgNotes.value;
+ els.fileToggles.querySelectorAll("[data-file-id]").forEach((inp) => {
+ if (!sel.files) sel.files = {};
+ sel.files[inp.dataset.fileId] = inp.checked;
+ });
}
+
renderAllMessages();
+ renderSidebar();
+ updateTriageBar();
scheduleAutosave();
}
@@ -1038,60 +1130,166 @@ function skipChannel() {
persistNotes();
}
+function syncGlobalSearchModeUi() {
+ const filesMode = els.globalSearchMode.value === "files";
+ els.globalFileType.classList.toggle("hidden", !filesMode);
+ els.globalSearch.placeholder = filesMode
+ ? "Search attachments in all channels… (optional keywords)"
+ : "Search channels & messages… (press /)";
+}
+
+async function jumpToSearchHit(ch, msgId) {
+ els.globalSearchResults.classList.add("hidden");
+ els.globalSearch.value = "";
+ await selectChannel(ch);
+ if (msgId) {
+ const msg = state.allMessages.find((m) => m.id === msgId);
+ if (msg) {
+ requestAnimationFrame(() => {
+ focusMessage(msg.id);
+ openMessageDetail(msg);
+ document
+ .querySelector(
+ `[data-id="${msgId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`
+ )
+ ?.scrollIntoView({ block: "center" });
+ });
+ }
+ }
+}
+
async function runGlobalSearch(query) {
- if (!query.trim()) {
+ const filesMode = els.globalSearchMode.value === "files";
+ if (!filesMode && !query.trim()) {
els.globalSearchResults.classList.add("hidden");
return;
}
- const data = await api(`/api/search?q=${encodeURIComponent(query)}&limit=60`);
const parts = [];
- if (data.channels?.length) {
- parts.push('
Channels
');
- for (const ch of data.channels.slice(0, 12)) {
- parts.push(
- `
`
- );
+
+ if (filesMode) {
+ const ft = els.globalFileType.value || "all";
+ const data = await api(
+ `/api/search-files?q=${encodeURIComponent(query)}&type=${encodeURIComponent(ft)}&limit=60`
+ );
+ if (data.hits?.length) {
+ const label =
+ ft === "all" ? "Attachments" : `${ft} files`;
+ parts.push(`
${escapeHtml(label)}
`);
+ for (const m of data.hits.slice(0, 25)) {
+ const names = (m.matching_files || m.files || [])
+ .map((f) => f.name)
+ .filter(Boolean)
+ .slice(0, 2)
+ .join(", ");
+ parts.push(
+ `
`
+ );
+ }
+ parts.push("
");
}
- parts.push("
");
- }
- if (data.messages?.length) {
- parts.push('
Messages
');
- for (const m of data.messages.slice(0, 20)) {
- parts.push(
- `
`
- );
+ } else {
+ const data = await api(`/api/search?q=${encodeURIComponent(query)}&limit=60`);
+ if (data.channels?.length) {
+ parts.push('
Channels
');
+ for (const ch of data.channels.slice(0, 12)) {
+ parts.push(
+ ``
+ );
+ }
+ parts.push("");
+ }
+ if (data.messages?.length) {
+ parts.push('
Messages
');
+ for (const m of data.messages.slice(0, 20)) {
+ parts.push(
+ `
`
+ );
+ }
+ parts.push("
");
}
- parts.push("
");
}
+
els.globalSearchResults.innerHTML =
parts.join("") || '
No results
';
els.globalSearchResults.classList.remove("hidden");
els.globalSearchResults.querySelectorAll(".search-hit").forEach((btn) => {
- btn.addEventListener("click", async () => {
- const ch = btn.dataset.channel;
- els.globalSearchResults.classList.add("hidden");
- els.globalSearch.value = "";
- await selectChannel(ch);
- const msgId = btn.dataset.msg;
- if (msgId) {
- const msg = state.allMessages.find((m) => m.id === msgId);
- if (msg) {
- requestAnimationFrame(() => {
- openMessageDetail(msg);
- document.querySelector(`[data-id="${msgId}"]`)?.scrollIntoView({
- block: "center",
- });
- });
- }
- }
- });
+ btn.addEventListener("click", () => jumpToSearchHit(btn.dataset.channel, btn.dataset.msg));
});
}
+function visibleFileMessages(imagesOnly) {
+ return visibleMessages().filter((msg) => {
+ const files = msg.files || [];
+ if (!files.length) return false;
+ if (imagesOnly && !files.some(isImageFile)) return false;
+ return true;
+ });
+}
+
+function selectVisibleFileMessages({ imagesOnly = false } = {}) {
+ if (!state.selectedChannel) return;
+ ensureCherryPick();
+ const targets = visibleFileMessages(imagesOnly);
+ const allSelected =
+ targets.length > 0 &&
+ targets.every((msg) => isMsgSelected(state.selectedChannel, msg));
+ let n = 0;
+ for (const msg of targets) {
+ const sel = msgEntry(state.selectedChannel, msg.id);
+ if (allSelected) {
+ clearMsgSelection(state.selectedChannel, msg.id);
+ } else {
+ setMsgImport(state.selectedChannel, msg, true);
+ const sel = msgEntry(state.selectedChannel, msg.id);
+ for (const f of msg.files || []) {
+ const fid = f.id || f.name;
+ if (!fid) continue;
+ if (imagesOnly && !isImageFile(f)) continue;
+ sel.files[fid] = true;
+ }
+ }
+ n += 1;
+ }
+ renderAllMessages();
+ renderSidebar();
+ updateTriageBar();
+ scheduleAutosave();
+ const label = imagesOnly ? "image" : "file";
+ const verb = allSelected ? "Cleared" : "Selected";
+ els.saveStatus.textContent = `${verb} ${n} ${label} message(s) in view`;
+ updateBulkSelectButtons();
+ setTimeout(() => {
+ els.saveStatus.textContent = "";
+ }, 2500);
+}
+
+function updateBulkSelectButtons() {
+ if (!els.btnSelectImages || !els.btnSelectFiles) return;
+ const imgTargets = visibleFileMessages(true);
+ const fileTargets = visibleFileMessages(false);
+ const imgAll =
+ imgTargets.length > 0 &&
+ imgTargets.every((m) => isMsgSelected(state.selectedChannel, m));
+ const fileAll =
+ fileTargets.length > 0 &&
+ fileTargets.every((m) => isMsgSelected(state.selectedChannel, m));
+ els.btnSelectImages.textContent = imgAll ? "Clear images in view" : "Select images in view";
+ els.btnSelectFiles.textContent = fileAll ? "Clear files in view" : "Select files in view";
+ els.btnSelectImages.classList.toggle("active", imgAll);
+ els.btnSelectFiles.classList.toggle("active", fileAll);
+}
+
+function selectVisibleImageMessages() {
+ selectVisibleFileMessages({ imagesOnly: true });
+}
+
function scheduleAutosave() {
clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(() => saveManifest(true), 600);
@@ -1131,11 +1329,15 @@ async function loadChannels() {
e.import = !!ch.import;
e.status = ch.status || (ch.import ? "import" : "undecided");
e.reviewed = !!ch.reviewed || ["import", "skip", "partial"].includes(e.status);
- e.import_mode = ch.import_mode || "all";
+ e.import_mode =
+ ch.import_mode === "pick" || ch.status === "partial" ? "pick" : ch.import_mode || "all";
+ if (ch.status === "partial" && e.import_mode !== "pick") {
+ e.import_mode = "pick";
+ }
e.notes = ch.notes || "";
e.mattermost_channel = ch.mattermost_channel || "";
e.mattermost_team = ch.mattermost_team || "";
- if (ch.message_selections) e.messages = ch.message_selections;
+ // Message picks come from loadManifest() only — avoid clobbering on parallel load.
}
}
@@ -1149,6 +1351,7 @@ async function loadManifest() {
function onMessageListClick(e) {
if (!state.selectedChannel) return;
+ if (e.target.closest(".msg-check")) return;
const previewBtn = e.target.closest(
".image-preview, .file-doc-preview, .file-open-link[data-preview-file-id], .file-open-link[data-preview-url]"
@@ -1165,27 +1368,6 @@ function onMessageListClick(e) {
return;
}
- const check = e.target.closest("[data-msg-check]");
- if (check) {
- e.stopPropagation();
- const row = check.closest(".message");
- const msg = state.allMessages.find((m) => m.id === row?.dataset.id);
- if (!msg) return;
- const sel = msgEntry(state.selectedChannel, msg.id);
- if (check.checked) {
- sel.action = "import";
- sel.checked = true;
- } else {
- sel.action = "skip";
- sel.checked = false;
- }
- ensureCherryPick();
- renderAllMessages();
- scheduleAutosave();
- focusMessage(msg.id, { scroll: false });
- return;
- }
-
const row = e.target.closest(".message");
if (!row) return;
const msg = state.allMessages.find((m) => m.id === row.dataset.id);
@@ -1212,6 +1394,7 @@ function bindEvents() {
[
els.messageSearch,
+ els.filterAttachmentType,
els.filterHideBots,
els.filterHideSystem,
els.filterOnlySelected,
@@ -1220,6 +1403,22 @@ function bindEvents() {
el.addEventListener("change", renderAllMessages);
});
+ els.btnSelectImages?.addEventListener("click", selectVisibleImageMessages);
+ els.btnSelectFiles?.addEventListener("click", () => selectVisibleFileMessages());
+ els.globalSearchMode.addEventListener("change", () => {
+ syncGlobalSearchModeUi();
+ const q = els.globalSearch.value;
+ if (els.globalSearchMode.value === "files" || q.trim()) {
+ runGlobalSearch(q);
+ } else {
+ els.globalSearchResults.classList.add("hidden");
+ }
+ });
+ els.globalFileType.addEventListener("change", () => {
+ if (els.globalSearchMode.value === "files") runGlobalSearch(els.globalSearch.value);
+ });
+ syncGlobalSearchModeUi();
+
els.importModePick.addEventListener("change", () => {
if (!state.selectedChannel) return;
const entry = channelEntry(state.selectedChannel);
@@ -1244,6 +1443,24 @@ function bindEvents() {
els.btnMsgPrev.addEventListener("click", goPrevMessage);
els.messages.addEventListener("click", onMessageListClick);
+ els.messages.addEventListener("change", (e) => {
+ const input = e.target.closest("input[data-msg-check]");
+ if (!input || !state.selectedChannel) return;
+ e.stopPropagation();
+ const row = input.closest(".message");
+ const msg = state.allMessages.find((m) => m.id === row?.dataset.id);
+ if (!msg) return;
+ setMsgImport(state.selectedChannel, msg, input.checked);
+ renderAllMessages();
+ renderSidebar();
+ updateTriageBar();
+ scheduleAutosave();
+ if (state.selectedMessageId === msg.id) {
+ openMessageDetail(msg);
+ } else {
+ focusMessage(msg.id, { scroll: false });
+ }
+ });
els.msgAction.addEventListener("change", () => {
els.msgMergeTarget.classList.toggle("hidden", els.msgAction.value !== "merge");
@@ -1266,7 +1483,7 @@ function bindEvents() {
els.globalSearch.addEventListener("input", () => {
clearTimeout(state.globalSearchTimer);
const q = els.globalSearch.value;
- if (!q.trim()) {
+ if (!q.trim() && els.globalSearchMode.value !== "files") {
els.globalSearchResults.classList.add("hidden");
return;
}
@@ -1358,7 +1575,13 @@ async function init() {
bindEvents();
try {
const summary = await api("/api/summary");
- await Promise.all([loadManifest(), loadChannels(), api("/api/users").then((d) => { state.users = d.users; })]);
+ await loadManifest();
+ await Promise.all([
+ loadChannels(),
+ api("/api/users").then((d) => {
+ state.users = d.users;
+ }),
+ ]);
renderStats(summary);
renderSidebar();
renderSelectionSummary();
diff --git a/static/index.html b/static/index.html
index 1c122ac..3d8ebec 100644
--- a/static/index.html
+++ b/static/index.html
@@ -12,12 +12,29 @@
Slack import review
@@ -33,7 +50,7 @@
-
+
@@ -73,6 +90,16 @@
—
+
+
+
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..1033f31
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,69 @@
+# Tests
+
+## Fixture (seeded from your export)
+
+`tests/fixtures/mini-export/` is a small slice of the real Levkin export:
+
+- `#medical`, `#zoey`, `#153niagara` (first 4 day-files each, ~54 messages)
+- Regenerate after export changes:
+
+```bash
+make fixture
+# or
+python3 scripts/build-test-fixture.py "/path/to/export"
+```
+
+## Python (unit + API)
+
+```bash
+make test-unit
+```
+
+Covers:
+
+- `slack_export.py` — parse, search, file types
+- `scripts/auto-cherry-pick.py` — drafts, data-only rules, return channels
+- `server.py` — HTTP API against mini-export
+
+Uses `.venv` automatically.
+
+## Playwright (UI)
+
+```bash
+make test-e2e
+```
+
+Covers:
+
+- Channel list, filters (**All** / **Has picks**)
+- Cherry-pick checkbox on/off
+- Detail panel **Skip**
+- **Only selected** filter
+- Global **Files** search
+
+Runs server on port **18765** with `tests/fixtures/test-manifest.json`.
+
+## All
+
+```bash
+make test
+```
+
+## Coverage
+
+```bash
+make test-unit # includes --cov report
+```
+
+Current targets (~**98%** Python, **11** Playwright tests):
+
+| Module | Coverage | Notes |
+|--------|----------|-------|
+| `slack_export.py` | ~98% | canvases index, HTTP 401, search limits |
+| `server.py` | ~98% | logging when not quiet, empty DOCX body |
+| `scripts/auto-cherry-pick.py` | via `test_auto_cherry_pick_main.py` | full `main()` on fixture |
+| `static/app.js` | Playwright e2e | bulk select, text search, save |
+
+**100** pytest cases + **11** e2e. Manifest is copied per session and reset before each API test so POST cases do not leak state.
+
+Server runs **in-process** during pytest; Playwright uses port **18765**.
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..d0b80c5
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,138 @@
+"""Pytest fixtures — mini-export seeded from real Levkin Slack data."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import shutil
+import socket
+import sys
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+FIXTURE_EXPORT = REPO_ROOT / "tests" / "fixtures" / "mini-export"
+TEST_MANIFEST = REPO_ROOT / "tests" / "fixtures" / "test-manifest.json"
+
+
+def _load_cherry_pick_module():
+ path = REPO_ROOT / "scripts" / "auto-cherry-pick.py"
+ spec = importlib.util.spec_from_file_location("auto_cherry_pick", path)
+ mod = importlib.util.module_from_spec(spec)
+ assert spec.loader
+ spec.loader.exec_module(mod)
+ return mod
+
+
+@pytest.fixture(scope="session")
+def cherry_pick():
+ return _load_cherry_pick_module()
+
+
+@pytest.fixture(scope="session")
+def fixture_export_path() -> Path:
+ assert FIXTURE_EXPORT.is_dir(), (
+ f"Run: python3 scripts/build-test-fixture.py ({FIXTURE_EXPORT})"
+ )
+ return FIXTURE_EXPORT
+
+
+@pytest.fixture
+def export(fixture_export_path):
+ sys.path.insert(0, str(REPO_ROOT))
+ from slack_export import SlackExport
+
+ return SlackExport(fixture_export_path)
+
+
+@pytest.fixture
+def empty_manifest(fixture_export_path, tmp_path):
+ data = {
+ "version": 1,
+ "export_path": str(fixture_export_path),
+ "updated_at": None,
+ "global_notes": "",
+ "channels": {},
+ "users": {},
+ }
+ path = tmp_path / "manifest.json"
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
+ return path, data
+
+
+def _free_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def _wait_health(url: str, timeout: float = 8.0) -> None:
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ try:
+ with urllib.request.urlopen(url, timeout=1) as resp:
+ if resp.status == 200:
+ return
+ except (urllib.error.URLError, TimeoutError):
+ time.sleep(0.15)
+ raise RuntimeError(f"Server did not start: {url}")
+
+
+@pytest.fixture(scope="session")
+def review_server(tmp_path_factory):
+ """Start review server in-process (for coverage) with isolated manifest copy."""
+ import os
+ import threading
+ from http.server import ThreadingHTTPServer
+
+ sys.path.insert(0, str(REPO_ROOT))
+ from server import ReviewHandler
+ from slack_export import SlackExport
+
+ port = _free_port()
+ manifest = tmp_path_factory.mktemp("manifest") / "manifest.json"
+ shutil.copy(TEST_MANIFEST, manifest)
+ export = SlackExport(FIXTURE_EXPORT)
+ handler_cls = type(
+ "BoundReviewHandler",
+ (ReviewHandler,),
+ {"export": export, "manifest_path": manifest},
+ )
+ os.environ["SLACK_IMPORT_QUIET"] = "1"
+ httpd = ThreadingHTTPServer(("127.0.0.1", port), handler_cls)
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
+ thread.start()
+ base = f"http://127.0.0.1:{port}"
+ try:
+ _wait_health(f"{base}/api/health")
+ yield {"base": base, "port": port, "httpd": httpd, "manifest_path": manifest}
+ finally:
+ httpd.shutdown()
+
+
+@pytest.fixture
+def api(review_server):
+ # Reset manifest so POST tests cannot leak state across cases.
+ shutil.copy(TEST_MANIFEST, review_server["manifest_path"])
+ base = review_server["base"]
+
+ def get(path: str) -> dict:
+ with urllib.request.urlopen(f"{base}{path}", timeout=5) as resp:
+ return json.loads(resp.read().decode())
+
+ def post(path: str, body: dict) -> dict:
+ data = json.dumps(body).encode("utf-8")
+ req = urllib.request.Request(
+ f"{base}{path}",
+ data=data,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ return json.loads(resp.read().decode())
+
+ return {"get": get, "post": post, "base": base}
diff --git a/tests/e2e/review-ui.spec.ts b/tests/e2e/review-ui.spec.ts
new file mode 100644
index 0000000..94e40dc
--- /dev/null
+++ b/tests/e2e/review-ui.spec.ts
@@ -0,0 +1,139 @@
+import { test, expect } from "@playwright/test";
+
+const BASE = process.env.SLACK_REVIEW_URL || "http://127.0.0.1:18765";
+
+async function waitForAppReady(page: import("@playwright/test").Page) {
+ await page.goto(BASE);
+ await expect(page.locator("#channel-title")).not.toHaveText("Select a channel", {
+ timeout: 15_000,
+ });
+}
+
+async function showAllChannels(page: import("@playwright/test").Page) {
+ await page.locator("#filter-status").selectOption("all");
+ await expect(page.locator("#sidebar-list .list-item").first()).toBeVisible();
+}
+
+async function openChannel(page: import("@playwright/test").Page, name: string) {
+ await showAllChannels(page);
+ const btn = page.locator("#sidebar-list button").filter({ hasText: name });
+ await btn.first().click();
+}
+
+test.describe("Review UI", () => {
+ test.beforeAll(async ({ request }) => {
+ const res = await request.get(`${BASE}/api/health`);
+ expect(res.ok()).toBeTruthy();
+ });
+
+ test("loads channel list and stats", async ({ page }) => {
+ await waitForAppReady(page);
+ await expect(page.locator("h1")).toHaveText("Slack import review");
+ await expect(page.locator("#stats")).not.toBeEmpty();
+ await expect(page.locator("#sidebar-list .list-item").first()).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ test("opens medical channel and shows messages", async ({ page }) => {
+ await waitForAppReady(page);
+ await openChannel(page, "medical");
+ await expect(page.locator("#channel-title")).toHaveText("#medical");
+ await expect(page.locator(".message").first()).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("cherry-pick checkbox toggles on and off", async ({ page }) => {
+ await waitForAppReady(page);
+ await openChannel(page, "medical");
+ await page.waitForSelector(".message", { timeout: 10_000 });
+
+ const row = page.locator(".message").first();
+ const checkbox = row.locator("input[data-msg-check]");
+ await checkbox.check();
+ await expect(row).toHaveClass(/sel-import/);
+
+ await checkbox.uncheck();
+ await expect(row).not.toHaveClass(/sel-import/);
+ });
+
+ test("detail panel skip clears selection", async ({ page }) => {
+ await waitForAppReady(page);
+ await openChannel(page, "medical");
+ await page.waitForSelector(".message", { timeout: 10_000 });
+
+ const row = page.locator(".message").first();
+ await row.click();
+ await expect(page.locator("#message-detail")).toBeVisible();
+ await page.locator("#msg-action").selectOption("import");
+ await expect(row).toHaveClass(/sel-import/);
+
+ await page.locator("#msg-action").selectOption("skip");
+ await expect(row).not.toHaveClass(/sel-import/);
+ });
+
+ test("filter only selected hides unchecked", async ({ page }) => {
+ await waitForAppReady(page);
+ await openChannel(page, "zoey");
+ await page.waitForSelector(".message", { timeout: 10_000 });
+
+ const before = await page.locator(".message").count();
+ const checkbox = page.locator(".message").first().locator("input[data-msg-check]");
+ await checkbox.check();
+ await page.locator("#filter-only-selected").check();
+ const after = await page.locator(".message").count();
+ expect(after).toBeLessThanOrEqual(before);
+ expect(after).toBeGreaterThanOrEqual(1);
+ });
+
+ test("global file search mode returns hits", async ({ page }) => {
+ await waitForAppReady(page);
+ await page.locator("#global-search-mode").selectOption("files");
+ await page.locator("#global-file-type").selectOption("image");
+ await page.waitForTimeout(400);
+ const results = page.locator("#global-search-results .search-hit");
+ const count = await results.count();
+ expect(count).toBeGreaterThanOrEqual(0);
+ });
+
+ test("sidebar Has picks filter", async ({ page }) => {
+ await waitForAppReady(page);
+ await page.locator("#filter-status").selectOption("partial");
+ const items = page.locator("#sidebar-list .list-item");
+ await expect(items.first()).toBeVisible();
+ const count = await items.count();
+ expect(count).toBeGreaterThanOrEqual(1);
+ });
+
+ test("import mode pick checkbox enables cherry-pick", async ({ page }) => {
+ await waitForAppReady(page);
+ await openChannel(page, "medical");
+ await page.locator("#import-mode-pick").check();
+ await expect(page.locator("#import-mode-pick")).toBeChecked();
+ });
+
+ test("save button shows confirmation", async ({ page }) => {
+ await waitForAppReady(page);
+ await page.locator("#btn-save").click();
+ await expect(page.locator("#save-status")).toContainText("Saved", { timeout: 3000 });
+ });
+
+ test("global text search finds channel", async ({ page }) => {
+ await waitForAppReady(page);
+ await page.locator("#global-search-mode").selectOption("text");
+ await page.locator("#global-search").fill("niagara");
+ await page.waitForTimeout(400);
+ await expect(page.locator("#global-search-results .search-hit").first()).toBeVisible();
+ });
+
+ test("select files in view toggles", async ({ page }) => {
+ await waitForAppReady(page);
+ await openChannel(page, "153niagara");
+ await page.waitForSelector(".message", { timeout: 10_000 });
+ await page.locator("#filter-attachment-type").selectOption("any");
+ const btn = page.locator("#btn-select-files");
+ await btn.click();
+ await expect(btn).toContainText("Clear", { timeout: 3000 });
+ await btn.click();
+ await expect(btn).toContainText("Select", { timeout: 3000 });
+ });
+});
diff --git a/tests/fixtures/mini-export/153niagara/2016-10-05.json b/tests/fixtures/mini-export/153niagara/2016-10-05.json
new file mode 100644
index 0000000..cbb003a
--- /dev/null
+++ b/tests/fixtures/mini-export/153niagara/2016-10-05.json
@@ -0,0 +1,17 @@
+[
+ {
+ "subtype": "channel_join",
+ "user": "U2HHNN1HQ",
+ "text": "<@U2HHNN1HQ> has joined the channel",
+ "type": "message",
+ "ts": "1475689651.000002"
+ },
+ {
+ "subtype": "channel_join",
+ "user": "U2J30FCLW",
+ "text": "<@U2J30FCLW> has joined the channel",
+ "inviter": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1475689652.000003"
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/153niagara/2016-10-11.json b/tests/fixtures/mini-export/153niagara/2016-10-11.json
new file mode 100644
index 0000000..0cb7f73
--- /dev/null
+++ b/tests/fixtures/mini-export/153niagara/2016-10-11.json
@@ -0,0 +1,198 @@
+[
+ {
+ "text": "<@U2HHNN1HQ|jedi_jew> shared a file:
",
+ "files": [
+ {
+ "id": "F049MQBLJBF",
+ "created": 1476210524,
+ "timestamp": 1476210524,
+ "mimetype": "application\/vnd.slack-docs",
+ "filetype": "quip",
+ "pretty_type": "Canvas",
+ "user": "U2HHNN1HQ",
+ "user_team": "T2HHNN1FU",
+ "editable": true,
+ "size": 1039,
+ "mode": "quip",
+ "is_external": false,
+ "external_type": "",
+ "is_public": true,
+ "public_url_shared": false,
+ "display_as_bot": false,
+ "username": "",
+ "name": "Proof_read",
+ "title": "Proof read",
+ "is_modified_by_ai": false,
+ "url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF",
+ "permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049MQBLJBF-8ae09c2c9a",
+ "url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF\/mobile",
+ "quip_thread_id": "EYc9AAYAZZK",
+ "updated": 1476210573,
+ "update_notification": null,
+ "canvas_readtime": 0.84945804897631,
+ "is_starred": false,
+ "skipped_shares": true,
+ "is_restricted_sharing_enabled": false,
+ "has_rich_preview": false,
+ "file_access": "visible",
+ "access": "owner",
+ "org_or_workspace_access": "read",
+ "canvas_creator_id": "U2HHNN1HQ",
+ "team_pref_version_history_enabled": true,
+ "canvas_printing_enabled": true,
+ "is_ai_suggested": false
+ }
+ ],
+ "upload": false,
+ "user": "U2HHNN1HQ",
+ "display_as_bot": false,
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "2ktK",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "user",
+ "user_id": "U2HHNN1HQ"
+ },
+ {
+ "type": "text",
+ "text": " shared a file: "
+ },
+ {
+ "type": "link",
+ "url": "https:\/\/levkine.slack.com\/files\/jedi_jew\/F2N37HDQA\/Proof_read",
+ "text": "Proof read"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "type": "message",
+ "ts": "1476210563.000002"
+ },
+ {
+ "subtype": "file_comment",
+ "text": "<@U2HHNN1HQ> commented on <@U2HHNN1HQ>’s file : You can proof read in the post",
+ "comment": {
+ "id": "Fc2N2M6J5A",
+ "created": 1476210563,
+ "timestamp": 1476210563,
+ "user": "U2HHNN1HQ",
+ "is_intro": false,
+ "comment": "You can proof read in the post"
+ },
+ "is_intro": false,
+ "file": {
+ "id": "F049MQBLJBF",
+ "created": 1476210524,
+ "timestamp": 1476210524,
+ "mimetype": "application\/vnd.slack-docs",
+ "filetype": "quip",
+ "pretty_type": "Canvas",
+ "user": "U2HHNN1HQ",
+ "user_team": "T2HHNN1FU",
+ "editable": true,
+ "size": 1039,
+ "mode": "quip",
+ "is_external": false,
+ "external_type": "",
+ "is_public": true,
+ "public_url_shared": false,
+ "display_as_bot": false,
+ "username": "",
+ "name": "Proof_read",
+ "title": "Proof read",
+ "is_modified_by_ai": false,
+ "url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF",
+ "permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049MQBLJBF-8ae09c2c9a",
+ "url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF\/mobile",
+ "quip_thread_id": "EYc9AAYAZZK",
+ "updated": 1476210573,
+ "update_notification": null,
+ "canvas_readtime": 0.84945804897631,
+ "is_starred": false,
+ "skipped_shares": true,
+ "is_restricted_sharing_enabled": false,
+ "has_rich_preview": false,
+ "file_access": "visible",
+ "access": "owner",
+ "org_or_workspace_access": "read",
+ "canvas_creator_id": "U2HHNN1HQ",
+ "team_pref_version_history_enabled": true,
+ "canvas_printing_enabled": true,
+ "is_ai_suggested": false
+ },
+ "files": [
+ {
+ "id": "F049MQBLJBF",
+ "created": 1476210524,
+ "timestamp": 1476210524,
+ "mimetype": "application\/vnd.slack-docs",
+ "filetype": "quip",
+ "pretty_type": "Canvas",
+ "user": "U2HHNN1HQ",
+ "user_team": "T2HHNN1FU",
+ "editable": true,
+ "size": 1039,
+ "mode": "quip",
+ "is_external": false,
+ "external_type": "",
+ "is_public": true,
+ "public_url_shared": false,
+ "display_as_bot": false,
+ "username": "",
+ "name": "Proof_read",
+ "title": "Proof read",
+ "is_modified_by_ai": false,
+ "url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF",
+ "permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049MQBLJBF-8ae09c2c9a",
+ "url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF\/mobile",
+ "quip_thread_id": "EYc9AAYAZZK",
+ "updated": 1476210573,
+ "update_notification": null,
+ "canvas_readtime": 0.84945804897631,
+ "is_starred": false,
+ "skipped_shares": true,
+ "is_restricted_sharing_enabled": false,
+ "has_rich_preview": false,
+ "file_access": "visible",
+ "access": "owner",
+ "org_or_workspace_access": "read",
+ "canvas_creator_id": "U2HHNN1HQ",
+ "team_pref_version_history_enabled": true,
+ "canvas_printing_enabled": true,
+ "is_ai_suggested": false
+ }
+ ],
+ "team": "T00",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "<@U2HHNN1HQ> commented on <@U2HHNN1HQ>’s file : You can proof read in the post"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "type": "message",
+ "ts": "1476210563.000003"
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/153niagara/2016-10-18.json b/tests/fixtures/mini-export/153niagara/2016-10-18.json
new file mode 100644
index 0000000..598ce01
--- /dev/null
+++ b/tests/fixtures/mini-export/153niagara/2016-10-18.json
@@ -0,0 +1,309 @@
+[
+ {
+ "text": "<@U2J30FCLW|ira_feeds> shared a file: ",
+ "files": [
+ {
+ "id": "F049QLYJZSN",
+ "created": 1476833849,
+ "timestamp": 1476833849,
+ "mimetype": "application\/vnd.slack-docs",
+ "filetype": "quip",
+ "pretty_type": "Canvas",
+ "user": "U2J30FCLW",
+ "user_team": "T2HHNN1FU",
+ "editable": true,
+ "size": 483,
+ "mode": "quip",
+ "is_external": false,
+ "external_type": "",
+ "is_public": true,
+ "public_url_shared": false,
+ "display_as_bot": false,
+ "username": "",
+ "name": "draft_-_hot_plate",
+ "title": "draft - hot plate",
+ "is_modified_by_ai": false,
+ "url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN",
+ "permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049QLYJZSN-1ded605162",
+ "url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN\/mobile",
+ "quip_thread_id": "QeY9AAYD2SZ",
+ "updated": 1476833915,
+ "update_notification": null,
+ "canvas_readtime": 0.40626254516259,
+ "is_starred": false,
+ "skipped_shares": true,
+ "is_restricted_sharing_enabled": false,
+ "has_rich_preview": false,
+ "file_access": "visible",
+ "access": "write",
+ "org_or_workspace_access": "write",
+ "canvas_creator_id": "U2J30FCLW",
+ "team_pref_version_history_enabled": true,
+ "canvas_printing_enabled": true,
+ "is_ai_suggested": false
+ }
+ ],
+ "upload": false,
+ "user": "U2J30FCLW",
+ "display_as_bot": false,
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "NzVx",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "user",
+ "user_id": "U2J30FCLW"
+ },
+ {
+ "type": "text",
+ "text": " shared a file: "
+ },
+ {
+ "type": "link",
+ "url": "https:\/\/levkine.slack.com\/files\/ira_feeds\/F2R3Y6L9L\/draft_-_hot_plate",
+ "text": "draft - hot plate"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "type": "message",
+ "ts": "1476833882.000005"
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1476833895.000006",
+ "text": "boom chikadee",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "93iAJ",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "boom chikadee"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1476833908.000007",
+ "text": "i dont have their emails on me, do you want to send it out?",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "H8ho",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "i dont have their emails on me, do you want to send it out?"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1476834078.000008",
+ "text": "sure",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "SbFFK",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "sure"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "subtype": "file_comment",
+ "text": "<@U2HHNN1HQ> commented on <@U2J30FCLW>’s file : perfect",
+ "comment": {
+ "id": "Fc2R4FEFRQ",
+ "created": 1476834088,
+ "timestamp": 1476834088,
+ "user": "U2HHNN1HQ",
+ "is_intro": false,
+ "comment": "perfect"
+ },
+ "is_intro": false,
+ "file": {
+ "id": "F049QLYJZSN",
+ "created": 1476833849,
+ "timestamp": 1476833849,
+ "mimetype": "application\/vnd.slack-docs",
+ "filetype": "quip",
+ "pretty_type": "Canvas",
+ "user": "U2J30FCLW",
+ "user_team": "T2HHNN1FU",
+ "editable": true,
+ "size": 483,
+ "mode": "quip",
+ "is_external": false,
+ "external_type": "",
+ "is_public": true,
+ "public_url_shared": false,
+ "display_as_bot": false,
+ "username": "",
+ "name": "draft_-_hot_plate",
+ "title": "draft - hot plate",
+ "is_modified_by_ai": false,
+ "url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN",
+ "permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049QLYJZSN-1ded605162",
+ "url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN\/mobile",
+ "quip_thread_id": "QeY9AAYD2SZ",
+ "updated": 1476833915,
+ "update_notification": null,
+ "canvas_readtime": 0.40626254516259,
+ "is_starred": false,
+ "skipped_shares": true,
+ "is_restricted_sharing_enabled": false,
+ "has_rich_preview": false,
+ "file_access": "visible",
+ "access": "write",
+ "org_or_workspace_access": "write",
+ "canvas_creator_id": "U2J30FCLW",
+ "team_pref_version_history_enabled": true,
+ "canvas_printing_enabled": true,
+ "is_ai_suggested": false
+ },
+ "files": [
+ {
+ "id": "F049QLYJZSN",
+ "created": 1476833849,
+ "timestamp": 1476833849,
+ "mimetype": "application\/vnd.slack-docs",
+ "filetype": "quip",
+ "pretty_type": "Canvas",
+ "user": "U2J30FCLW",
+ "user_team": "T2HHNN1FU",
+ "editable": true,
+ "size": 483,
+ "mode": "quip",
+ "is_external": false,
+ "external_type": "",
+ "is_public": true,
+ "public_url_shared": false,
+ "display_as_bot": false,
+ "username": "",
+ "name": "draft_-_hot_plate",
+ "title": "draft - hot plate",
+ "is_modified_by_ai": false,
+ "url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN",
+ "permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049QLYJZSN-1ded605162",
+ "url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN\/mobile",
+ "quip_thread_id": "QeY9AAYD2SZ",
+ "updated": 1476833915,
+ "update_notification": null,
+ "canvas_readtime": 0.40626254516259,
+ "is_starred": false,
+ "skipped_shares": true,
+ "is_restricted_sharing_enabled": false,
+ "has_rich_preview": false,
+ "file_access": "visible",
+ "access": "write",
+ "org_or_workspace_access": "write",
+ "canvas_creator_id": "U2J30FCLW",
+ "team_pref_version_history_enabled": true,
+ "canvas_printing_enabled": true,
+ "is_ai_suggested": false
+ }
+ ],
+ "team": "T00",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "<@U2HHNN1HQ> commented on <@U2J30FCLW>’s file : perfect"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "type": "message",
+ "ts": "1476834088.000009"
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/153niagara/2016-10-24.json b/tests/fixtures/mini-export/153niagara/2016-10-24.json
new file mode 100644
index 0000000..9363f8c
--- /dev/null
+++ b/tests/fixtures/mini-export/153niagara/2016-10-24.json
@@ -0,0 +1,298 @@
+[
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1477341477.000002",
+ "text": "someone from easyhome just came by for kerry",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "KaUr\/",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "someone from easyhome just came by for kerry"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1477341924.000003",
+ "text": "and?",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "cbcz",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "and?"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1477342010.000004",
+ "text": "he didnt say anything. i mentioned she has aliases, thought maybe if we give them to easyhome, they can track her down and it might be easier for us to collect on what she owes",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "31r",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "he didnt say anything. i mentioned she has aliases, thought maybe if we give them to easyhome, they can track her down and it might be easier for us to collect on what she owes"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1477342047.000005",
+ "text": "i emailed the collection agency today",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "uUcSc",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "i emailed the collection agency today"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1477342051.000006",
+ "text": "did they ask about the bed?",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "KyL8",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "did they ask about the bed?"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1477342059.000007",
+ "text": "nope, and i didnt say anything",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "Z+r2R",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "nope, and i didnt say anything"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1477342065.000008",
+ "text": "good",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "kRTUp",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "good"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1477342068.000009",
+ "text": "i really want to keep it",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "F2F9",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "i really want to keep it"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/__uploads/F011NH76RUP/IMG_20200418_090219.jpg b/tests/fixtures/mini-export/__uploads/F011NH76RUP/IMG_20200418_090219.jpg
new file mode 100644
index 0000000..006c3a3
Binary files /dev/null and b/tests/fixtures/mini-export/__uploads/F011NH76RUP/IMG_20200418_090219.jpg differ
diff --git a/tests/fixtures/mini-export/__uploads/T1/note.txt b/tests/fixtures/mini-export/__uploads/T1/note.txt
new file mode 100644
index 0000000..0f0d8d7
--- /dev/null
+++ b/tests/fixtures/mini-export/__uploads/T1/note.txt
@@ -0,0 +1 @@
+fixture preview text
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/channels.json b/tests/fixtures/mini-export/channels.json
new file mode 100644
index 0000000..464719a
--- /dev/null
+++ b/tests/fixtures/mini-export/channels.json
@@ -0,0 +1,68 @@
+[
+ {
+ "id": "C0121TJUSUD",
+ "name": "zoey",
+ "created": 1587214815,
+ "creator": "U2HHNN1HQ",
+ "is_archived": false,
+ "is_general": false,
+ "members": [
+ "U2HHNN1HQ",
+ "U2J30FCLW"
+ ],
+ "topic": {
+ "value": "",
+ "creator": "",
+ "last_set": 0
+ },
+ "purpose": {
+ "value": "",
+ "creator": "",
+ "last_set": 0
+ }
+ },
+ {
+ "id": "C2KPK5G8N",
+ "name": "153niagara",
+ "created": 1475689652,
+ "creator": "U2HHNN1HQ",
+ "is_archived": false,
+ "is_general": false,
+ "members": [
+ "U2HHNN1HQ",
+ "U2J30FCLW"
+ ],
+ "topic": {
+ "value": "",
+ "creator": "",
+ "last_set": 0
+ },
+ "purpose": {
+ "value": "",
+ "creator": "",
+ "last_set": 0
+ }
+ },
+ {
+ "id": "C2KQHKAV8",
+ "name": "medical",
+ "created": 1475691337,
+ "creator": "U2HHNN1HQ",
+ "is_archived": false,
+ "is_general": false,
+ "members": [
+ "U2HHNN1HQ",
+ "U2J30FCLW"
+ ],
+ "topic": {
+ "value": "",
+ "creator": "",
+ "last_set": 0
+ },
+ "purpose": {
+ "value": "",
+ "creator": "",
+ "last_set": 0
+ }
+ }
+]
diff --git a/tests/fixtures/mini-export/medical/2016-10-05.json b/tests/fixtures/mini-export/medical/2016-10-05.json
new file mode 100644
index 0000000..a11dc5d
--- /dev/null
+++ b/tests/fixtures/mini-export/medical/2016-10-05.json
@@ -0,0 +1,17 @@
+[
+ {
+ "subtype": "channel_join",
+ "user": "U2HHNN1HQ",
+ "text": "<@U2HHNN1HQ> has joined the channel",
+ "type": "message",
+ "ts": "1475691337.000002"
+ },
+ {
+ "subtype": "channel_join",
+ "user": "U2J30FCLW",
+ "text": "<@U2J30FCLW> has joined the channel",
+ "inviter": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1475691337.000003"
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/medical/2016-10-10.json b/tests/fixtures/mini-export/medical/2016-10-10.json
new file mode 100644
index 0000000..6febb39
--- /dev/null
+++ b/tests/fixtures/mini-export/medical/2016-10-10.json
@@ -0,0 +1,261 @@
+[
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1476122672.000002",
+ "text": "subclinical hypothyroidism d\/t autoimmunity = hashimoto's thyroiditis.",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "xcov",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "subclinical hypothyroidism d\/t autoimmunity = hashimoto's thyroiditis."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1476122755.000003",
+ "text": "alterations in thyroid functioning, in this case hypothyroidism manifest as reduced appetite, low energy, chronic infections (hello - persistent GI infections), weight loss, skin issues, CONSTIPATION, and literally all of my other symptoms",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "ME3ih",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "alterations in thyroid functioning, in this case hypothyroidism manifest as reduced appetite, low energy, chronic infections (hello - persistent GI infections), weight loss, skin issues, CONSTIPATION, and literally all of my other symptoms"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1476122785.000004",
+ "text": "So how do we fix it?",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "Ojdhx",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "So how do we fix it?"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1476122816.000005",
+ "text": "its a chicken and egg thing - dont know if the hashimoto's or the GI issues came first so need to fix both at same time",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "9+Oie",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "its a chicken and egg thing - dont know if the hashimoto's or the GI issues came first so need to fix both at same time"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1476122850.000006",
+ "text": "synthetic thyroid (already on it) and fixing leaky gut (may need to do new food sensitivity testing)",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "OlG",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "synthetic thyroid (already on it) and fixing leaky gut (may need to do new food sensitivity testing)"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1476122864.000007",
+ "text": "the synthroid may be too low a dose",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "+6S",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "the synthroid may be too low a dose"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1476122903.000008",
+ "text": "but we'll see",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "oZKHf",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "but we'll see"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/medical/2016-10-27.json b/tests/fixtures/mini-export/medical/2016-10-27.json
new file mode 100644
index 0000000..ee239b9
--- /dev/null
+++ b/tests/fixtures/mini-export/medical/2016-10-27.json
@@ -0,0 +1,241 @@
+[
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1477621049.000002",
+ "text": "",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "attachments": [
+ {
+ "service_name": "Tweed",
+ "title": "OCEAN VIEW (Leafs by Snoop)",
+ "title_link": "https:\/\/www.tweed.com\/collections\/available\/products\/ocean-view-leafs-by-snoop",
+ "text": "THC: 15.7\/ CBD: <0.05 ↓Also of Note THC and CBD amounts listed for this strain have been tested by Tweed. This product has not been cold-pasteurized. To lear",
+ "fallback": "Tweed: OCEAN VIEW (Leafs by Snoop)",
+ "thumb_url": "http:\/\/cdn.shopify.com\/s\/files\/1\/0266\/0901\/products\/Untitled-4_grande.png?v=1477513626",
+ "thumb_width": 41,
+ "thumb_height": 40,
+ "fields": [
+ {
+ "title": "Price",
+ "value": "From $9.60 CAD",
+ "short": true
+ },
+ {
+ "title": "Brand",
+ "value": "Leafs by Snoop",
+ "short": true
+ }
+ ],
+ "from_url": "https:\/\/www.tweed.com\/collections\/available\/products\/ocean-view-leafs-by-snoop",
+ "service_icon": "http:\/\/cdn.shopify.com\/s\/files\/1\/0266\/0901\/t\/16\/assets\/favicon.png?17894041713191168692",
+ "id": 1
+ }
+ ],
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "KpkG",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "link",
+ "url": "https:\/\/www.tweed.com\/collections\/available\/products\/ocean-view-leafs-by-snoop"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1477621066.000004",
+ "text": "nice",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "Il6oK",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "nice"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1477621108.000005",
+ "text": "",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "attachments": [
+ {
+ "service_name": "Tweed",
+ "title": "PALM TREE CBD (Leafs By Snoop)",
+ "title_link": "https:\/\/www.tweed.com\/collections\/available\/products\/palm-tree-cbd-leafs-by-snoop",
+ "text": "THC: 8.71\/ CBD: 9.76 ↓Also of Note THC and CBD amounts listed for this strain have been tested by Tweed. This product has not been cold-pasteurized. To learn mo",
+ "fallback": "Tweed: PALM TREE CBD (Leafs By Snoop)",
+ "thumb_url": "http:\/\/cdn.shopify.com\/s\/files\/1\/0266\/0901\/products\/Untitled-44_grande.png?v=1477513710",
+ "thumb_width": 41,
+ "thumb_height": 40,
+ "fields": [
+ {
+ "title": "Price",
+ "value": "From $7.20 CAD",
+ "short": true
+ },
+ {
+ "title": "Brand",
+ "value": "Leafs by Snoop",
+ "short": true
+ }
+ ],
+ "from_url": "https:\/\/www.tweed.com\/collections\/available\/products\/palm-tree-cbd-leafs-by-snoop",
+ "service_icon": "http:\/\/cdn.shopify.com\/s\/files\/1\/0266\/0901\/t\/16\/assets\/favicon.png?17894041713191168692",
+ "id": 1
+ }
+ ],
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "ueWRO",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "link",
+ "url": "https:\/\/www.tweed.com\/collections\/available\/products\/palm-tree-cbd-leafs-by-snoop"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1477621115.000007",
+ "text": "that one is similar to Devon",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "FwcA",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "that one is similar to Devon"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1477621164.000008",
+ "text": "the breakdown is a bit strange though - its supposedly focusing and sleepy at the same time",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "thl",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "the breakdown is a bit strange though - its supposedly focusing and sleepy at the same time"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/medical/2018-09-13.json b/tests/fixtures/mini-export/medical/2018-09-13.json
new file mode 100644
index 0000000..2510f0a
--- /dev/null
+++ b/tests/fixtures/mini-export/medical/2018-09-13.json
@@ -0,0 +1,84 @@
+[
+ {
+ "text": "",
+ "files": [
+ {
+ "id": "FCT24D5B7",
+ "created": 1536844410,
+ "timestamp": 1536844410,
+ "mimetype": "application\/pdf",
+ "filetype": "pdf",
+ "pretty_type": "PDF",
+ "user": "U2HHNN1HQ",
+ "user_team": "T2HHNN1FU",
+ "editable": false,
+ "size": 8190867,
+ "mode": "hosted",
+ "is_external": false,
+ "external_type": "",
+ "is_public": true,
+ "public_url_shared": false,
+ "display_as_bot": false,
+ "username": "",
+ "name": "img20180913_09120499.pdf",
+ "title": "img20180913_09120499.pdf",
+ "is_modified_by_ai": false,
+ "url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-FCT24D5B7\/img20180913_09120499.pdf?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-FCT24D5B7\/download\/img20180913_09120499.pdf?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "media_display_type": "unknown",
+ "thumb_pdf": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-FCT24D5B7-45e94cde7b\/img20180913_09120499_thumb_pdf.png?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "thumb_pdf_w": 930,
+ "thumb_pdf_h": 1196,
+ "permalink": "https:\/\/levkine.slack.com\/files\/U2HHNN1HQ\/FCT24D5B7\/img20180913_09120499.pdf",
+ "permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-FCT24D5B7-e76617f641",
+ "is_starred": false,
+ "skipped_shares": true,
+ "has_rich_preview": false,
+ "file_access": "visible"
+ }
+ ],
+ "upload": true,
+ "user": "U2HHNN1HQ",
+ "display_as_bot": false,
+ "type": "message",
+ "ts": "1536844412.000100"
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1536844629.000100",
+ "client_msg_id": "a0e588b0-0bf3-442a-bf02-edfbea2bec31",
+ "text": "Can you email so I can forward? ",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "Bv=9",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Can you email so I can forward?"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/users.json b/tests/fixtures/mini-export/users.json
new file mode 100644
index 0000000..b7a84ff
--- /dev/null
+++ b/tests/fixtures/mini-export/users.json
@@ -0,0 +1,933 @@
+[
+ {
+ "id": "U2HHNN1HQ",
+ "team_id": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "deleted": false,
+ "color": "9f69e7",
+ "real_name": "Ilia Dobkin",
+ "tz": "America/New_York",
+ "tz_label": "Eastern Daylight Time",
+ "tz_offset": -14400,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Ilia Dobkin",
+ "real_name_normalized": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "display_name_normalized": "Ilia",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "gde41167d309",
+ "huddle_state": "default_unset",
+ "huddle_state_expiration_ts": 0,
+ "first_name": "Ilia",
+ "last_name": "Dobkin",
+ "image_24": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-24.png",
+ "image_32": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-32.png",
+ "image_48": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-48.png",
+ "image_72": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "image_192": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-192.png",
+ "image_512": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-512.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_admin": true,
+ "is_owner": true,
+ "is_primary_owner": true,
+ "is_restricted": false,
+ "is_ultra_restricted": false,
+ "is_bot": false,
+ "is_app_user": false,
+ "updated": 1747338096,
+ "is_email_confirmed": true,
+ "who_can_share_contact_card": "EVERYONE"
+ },
+ {
+ "id": "U2J30FCLW",
+ "team_id": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "deleted": false,
+ "color": "4bbe2e",
+ "real_name": "Irina",
+ "tz": "America/New_York",
+ "tz_label": "Eastern Daylight Time",
+ "tz_offset": -14400,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Irina",
+ "real_name_normalized": "Irina",
+ "display_name": "ira_feeds",
+ "display_name_normalized": "ira_feeds",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "gc49a37283df",
+ "first_name": "Irina",
+ "last_name": "",
+ "image_24": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-24.png",
+ "image_32": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-32.png",
+ "image_48": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-48.png",
+ "image_72": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "image_192": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-192.png",
+ "image_512": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-512.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_admin": true,
+ "is_owner": false,
+ "is_primary_owner": false,
+ "is_restricted": false,
+ "is_ultra_restricted": false,
+ "is_bot": false,
+ "is_app_user": false,
+ "updated": 1626449137,
+ "is_email_confirmed": true,
+ "who_can_share_contact_card": "EVERYONE"
+ },
+ {
+ "id": "U2KQS74Q0",
+ "team_id": "T2HHNN1FU",
+ "name": "humblebot",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Humblebot",
+ "real_name_normalized": "Humblebot",
+ "display_name": "humblebot",
+ "display_name_normalized": "humblebot",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "b11cb3deda79",
+ "api_app_id": "A0H11TLMD",
+ "image_original": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_original.png",
+ "is_custom_image": true,
+ "bot_id": "B2KR3BLG1",
+ "first_name": "Humblebot",
+ "image_24": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_24.png",
+ "image_32": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_32.png",
+ "image_48": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_48.png",
+ "image_72": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_72.png",
+ "image_192": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_192.png",
+ "image_512": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_512.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1742354811
+ },
+ {
+ "id": "U3WCAAHV4",
+ "team_id": "T2HHNN1FU",
+ "name": "deactivateduser196138",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Deactivated User",
+ "real_name_normalized": "Deactivated User",
+ "display_name": "deactivateduser",
+ "display_name_normalized": "deactivateduser",
+ "fields": null,
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "g849cc56ed76",
+ "huddle_state": "default_unset",
+ "first_name": "Deactivated",
+ "last_name": "User",
+ "image_24": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-24.png",
+ "image_32": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-32.png",
+ "image_48": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-48.png",
+ "image_72": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-72.png",
+ "image_192": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-192.png",
+ "image_512": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-512.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": false,
+ "is_app_user": false,
+ "updated": 1748398757,
+ "is_forgotten": true
+ },
+ {
+ "id": "UPE67TA20",
+ "team_id": "T2HHNN1FU",
+ "name": "google_calendar",
+ "deleted": false,
+ "color": "df3dc0",
+ "real_name": "Google Calendar",
+ "tz": "America/Los_Angeles",
+ "tz_label": "Pacific Daylight Time",
+ "tz_offset": -25200,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Google Calendar",
+ "real_name_normalized": "Google Calendar",
+ "display_name": "",
+ "display_name_normalized": "",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "379b3e470f6f",
+ "api_app_id": "ADZ494LHY",
+ "always_active": true,
+ "image_original": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_original.png",
+ "is_custom_image": true,
+ "bot_id": "BPE67T9PW",
+ "image_24": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_24.png",
+ "image_32": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_32.png",
+ "image_48": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_48.png",
+ "image_72": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_72.png",
+ "image_192": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_192.png",
+ "image_512": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_1024.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_admin": false,
+ "is_owner": false,
+ "is_primary_owner": false,
+ "is_restricted": false,
+ "is_ultra_restricted": false,
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1771487052,
+ "is_email_confirmed": false,
+ "who_can_share_contact_card": "EVERYONE"
+ },
+ {
+ "id": "U06GCU7DDHU",
+ "team_id": "T2HHNN1FU",
+ "name": "aiintern",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "AI Intern",
+ "real_name_normalized": "AI Intern",
+ "display_name": "",
+ "display_name_normalized": "",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "7eedd5bf639b",
+ "api_app_id": "A04KKT0TQ2W",
+ "always_active": true,
+ "image_original": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_original.jpg",
+ "is_custom_image": true,
+ "bot_id": "B06H8B5JHR6",
+ "first_name": "AI",
+ "last_name": "Intern",
+ "image_24": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_24.jpg",
+ "image_32": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_32.jpg",
+ "image_48": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_48.jpg",
+ "image_72": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_72.jpg",
+ "image_192": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_192.jpg",
+ "image_512": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_512.jpg",
+ "image_1024": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_1024.jpg",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1742354804
+ },
+ {
+ "id": "U08JDB604N6",
+ "team_id": "T2HHNN1FU",
+ "name": "mural-bot",
+ "deleted": false,
+ "color": "e85d72",
+ "real_name": "MURAL",
+ "tz": "America/Los_Angeles",
+ "tz_label": "Pacific Daylight Time",
+ "tz_offset": -25200,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "MURAL",
+ "real_name_normalized": "MURAL",
+ "display_name": "",
+ "display_name_normalized": "",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "e36bc8361c8f",
+ "api_app_id": "A4LV0TKR9",
+ "always_active": true,
+ "image_original": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_original.jpg",
+ "is_custom_image": true,
+ "bot_id": "B08JDB604LS",
+ "first_name": "MURAL",
+ "last_name": "",
+ "image_24": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_24.jpg",
+ "image_32": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_32.jpg",
+ "image_48": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_48.jpg",
+ "image_72": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_72.jpg",
+ "image_192": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_192.jpg",
+ "image_512": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_512.jpg",
+ "image_1024": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_1024.jpg",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_admin": false,
+ "is_owner": false,
+ "is_primary_owner": false,
+ "is_restricted": false,
+ "is_ultra_restricted": false,
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1742354230,
+ "is_email_confirmed": false,
+ "who_can_share_contact_card": "EVERYONE"
+ },
+ {
+ "id": "U2KQP4VUZ",
+ "team_id": "T2HHNN1FU",
+ "name": "meekan",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Meekan Scheduling",
+ "real_name_normalized": "Meekan Scheduling",
+ "display_name": "meekan",
+ "display_name_normalized": "meekan",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "7d4b8d227280",
+ "api_app_id": "A0G51AT60",
+ "image_original": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_original.png",
+ "is_custom_image": true,
+ "bot_id": "B2KQCB85C",
+ "first_name": "Meekan",
+ "last_name": "Scheduling",
+ "image_24": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_24.png",
+ "image_32": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_32.png",
+ "image_48": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_48.png",
+ "image_72": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_72.png",
+ "image_192": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_192.png",
+ "image_512": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_1024.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1742354835
+ },
+ {
+ "id": "U2KR6LA3B",
+ "team_id": "T2HHNN1FU",
+ "name": "howdy",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Howdy",
+ "real_name_normalized": "Howdy",
+ "display_name": "howdy",
+ "display_name_normalized": "howdy",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "26e5aa1363cf",
+ "api_app_id": "A09RDP4AW",
+ "image_original": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_original.png",
+ "is_custom_image": true,
+ "bot_id": "B2KQVTFUN",
+ "first_name": "Howdy",
+ "image_24": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_24.png",
+ "image_32": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_32.png",
+ "image_48": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_48.png",
+ "image_72": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_72.png",
+ "image_192": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_192.png",
+ "image_512": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_1024.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1504348811
+ },
+ {
+ "id": "U35CW23UZ",
+ "team_id": "T2HHNN1FU",
+ "name": "leo_officevibe_bot",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Leo (Officevibe Bot)",
+ "real_name_normalized": "Leo (Officevibe Bot)",
+ "display_name": "leo_officevibe_bot",
+ "display_name_normalized": "leo_officevibe_bot",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "65c14a601f21",
+ "api_app_id": "A0GU27WR1",
+ "always_active": false,
+ "image_original": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_original.png",
+ "is_custom_image": true,
+ "bot_id": "B35BYVB7A",
+ "first_name": "Leo (Officevibe Bot)",
+ "image_24": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_24.png",
+ "image_32": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_32.png",
+ "image_48": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_48.png",
+ "image_72": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_72.png",
+ "image_192": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_192.png",
+ "image_512": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_512.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1504349194
+ },
+ {
+ "id": "U3660DH2R",
+ "team_id": "T2HHNN1FU",
+ "name": "jewbot",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "jewbot",
+ "real_name_normalized": "jewbot",
+ "display_name": "",
+ "display_name_normalized": "",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "7ab64cd294c2",
+ "api_app_id": "",
+ "always_active": false,
+ "image_original": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_original.png",
+ "is_custom_image": true,
+ "bot_id": "B365PU67N",
+ "image_24": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_24.png",
+ "image_32": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_32.png",
+ "image_48": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_48.png",
+ "image_72": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_72.png",
+ "image_192": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_192.png",
+ "image_512": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_512.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1742354829
+ },
+ {
+ "id": "UBMJ5M7CZ",
+ "team_id": "T2HHNN1FU",
+ "name": "googledrive",
+ "deleted": false,
+ "color": "5b89d5",
+ "real_name": "Google Drive",
+ "tz": "America/Los_Angeles",
+ "tz_label": "Pacific Daylight Time",
+ "tz_offset": -25200,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Google Drive",
+ "real_name_normalized": "Google Drive",
+ "display_name": "",
+ "display_name_normalized": "",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "a74495570a68",
+ "api_app_id": "A6NL8MJ6Q",
+ "always_active": true,
+ "image_original": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_original.png",
+ "is_custom_image": true,
+ "bot_id": "BBMT728UU",
+ "first_name": "Google",
+ "last_name": "Drive",
+ "image_24": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_24.png",
+ "image_32": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_32.png",
+ "image_48": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_48.png",
+ "image_72": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_72.png",
+ "image_192": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_192.png",
+ "image_512": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_1024.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_admin": false,
+ "is_owner": false,
+ "is_primary_owner": false,
+ "is_restricted": false,
+ "is_ultra_restricted": false,
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1602813021,
+ "is_email_confirmed": false,
+ "who_can_share_contact_card": "EVERYONE"
+ },
+ {
+ "id": "ULBNL5U3X",
+ "team_id": "T2HHNN1FU",
+ "name": "deactivateduser536011",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Deactivated User",
+ "real_name_normalized": "Deactivated User",
+ "display_name": "deactivateduser",
+ "display_name_normalized": "deactivateduser",
+ "fields": null,
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "g849cc56ed76",
+ "huddle_state": "default_unset",
+ "first_name": "Deactivated",
+ "last_name": "User",
+ "image_24": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-24.png",
+ "image_32": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-32.png",
+ "image_48": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-48.png",
+ "image_72": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-72.png",
+ "image_192": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-192.png",
+ "image_512": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-512.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": false,
+ "is_app_user": false,
+ "updated": 1748398744,
+ "is_forgotten": true
+ },
+ {
+ "id": "UP862QZ2M",
+ "team_id": "T2HHNN1FU",
+ "name": "deactivateduser867192",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Deactivated User",
+ "real_name_normalized": "Deactivated User",
+ "display_name": "deactivateduser",
+ "display_name_normalized": "deactivateduser",
+ "fields": null,
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "g849cc56ed76",
+ "huddle_state": "default_unset",
+ "first_name": "Deactivated",
+ "last_name": "User",
+ "image_24": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-24.png",
+ "image_32": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-32.png",
+ "image_48": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-48.png",
+ "image_72": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-72.png",
+ "image_192": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-192.png",
+ "image_512": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-512.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": false,
+ "is_app_user": false,
+ "updated": 1748398741,
+ "is_forgotten": true
+ },
+ {
+ "id": "UP8BUNWAV",
+ "team_id": "T2HHNN1FU",
+ "name": "deactivateduser876224",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Deactivated User",
+ "real_name_normalized": "Deactivated User",
+ "display_name": "deactivateduser",
+ "display_name_normalized": "deactivateduser",
+ "fields": null,
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "g849cc56ed76",
+ "huddle_state": "default_unset",
+ "first_name": "Deactivated",
+ "last_name": "User",
+ "image_24": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-24.png",
+ "image_32": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-32.png",
+ "image_48": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-48.png",
+ "image_72": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-72.png",
+ "image_192": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-192.png",
+ "image_512": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-512.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": false,
+ "is_app_user": false,
+ "updated": 1748398749,
+ "is_forgotten": true
+ },
+ {
+ "id": "UQF26AT1R",
+ "team_id": "T2HHNN1FU",
+ "name": "deactivateduser911684",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Deactivated User",
+ "real_name_normalized": "Deactivated User",
+ "display_name": "deactivateduser",
+ "display_name_normalized": "deactivateduser",
+ "fields": null,
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "g849cc56ed76",
+ "huddle_state": "default_unset",
+ "first_name": "Deactivated",
+ "last_name": "User",
+ "image_24": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-24.png",
+ "image_32": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-32.png",
+ "image_48": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-48.png",
+ "image_72": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-72.png",
+ "image_192": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-192.png",
+ "image_512": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-512.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": false,
+ "is_app_user": false,
+ "updated": 1748398765,
+ "is_forgotten": true
+ },
+ {
+ "id": "U04ARJ3A7LM",
+ "team_id": "T2HHNN1FU",
+ "name": "asana",
+ "deleted": false,
+ "color": "d58247",
+ "real_name": "Asana",
+ "tz": "America/Los_Angeles",
+ "tz_label": "Pacific Daylight Time",
+ "tz_offset": -25200,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Asana",
+ "real_name_normalized": "Asana",
+ "display_name": "",
+ "display_name_normalized": "",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "3b8995f549dc",
+ "api_app_id": "A01734836JY",
+ "always_active": true,
+ "image_original": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_original.png",
+ "is_custom_image": true,
+ "bot_id": "B04AUGF0ZUJ",
+ "first_name": "Asana",
+ "last_name": "",
+ "image_24": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_24.png",
+ "image_32": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_32.png",
+ "image_48": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_48.png",
+ "image_72": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_72.png",
+ "image_192": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_192.png",
+ "image_512": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_1024.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_admin": false,
+ "is_owner": false,
+ "is_primary_owner": false,
+ "is_restricted": false,
+ "is_ultra_restricted": false,
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1726350133,
+ "is_email_confirmed": false,
+ "who_can_share_contact_card": "EVERYONE"
+ },
+ {
+ "id": "U08HX1HNW31",
+ "team_id": "T2HHNN1FU",
+ "name": "otter",
+ "deleted": false,
+ "color": "827327",
+ "real_name": "Otter.ai",
+ "tz": "America/Los_Angeles",
+ "tz_label": "Pacific Daylight Time",
+ "tz_offset": -25200,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Otter.ai",
+ "real_name_normalized": "Otter.ai",
+ "display_name": "",
+ "display_name_normalized": "",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "c1628076c3b3",
+ "api_app_id": "A04ULDS8UFN",
+ "always_active": true,
+ "image_original": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_original.png",
+ "is_custom_image": true,
+ "bot_id": "B08HX1HNPST",
+ "first_name": "Otter.ai",
+ "last_name": "",
+ "image_24": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_24.png",
+ "image_32": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_32.png",
+ "image_48": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_48.png",
+ "image_72": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_72.png",
+ "image_192": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_192.png",
+ "image_512": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_1024.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_admin": false,
+ "is_owner": false,
+ "is_primary_owner": false,
+ "is_restricted": false,
+ "is_ultra_restricted": false,
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1742353383,
+ "is_email_confirmed": false,
+ "who_can_share_contact_card": "EVERYONE"
+ },
+ {
+ "id": "U08JDP2SQRZ",
+ "team_id": "T2HHNN1FU",
+ "name": "miro",
+ "deleted": true,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Miro",
+ "real_name_normalized": "Miro",
+ "display_name": "",
+ "display_name_normalized": "",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "c8beb7d87ed3",
+ "api_app_id": "A8VK125AS",
+ "always_active": true,
+ "image_original": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_original.png",
+ "is_custom_image": true,
+ "bot_id": "B08JDP2SDFD",
+ "first_name": "Miro",
+ "last_name": "",
+ "image_24": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_24.png",
+ "image_32": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_32.png",
+ "image_48": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_48.png",
+ "image_72": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_72.png",
+ "image_192": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_192.png",
+ "image_512": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_1024.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1772212905
+ },
+ {
+ "id": "U08SZSAHH5F",
+ "team_id": "T2HHNN1FU",
+ "name": "wf_bot_a08szs6b62v",
+ "deleted": false,
+ "color": "902d59",
+ "real_name": "Item status notification",
+ "tz": "America/Los_Angeles",
+ "tz_label": "Pacific Daylight Time",
+ "tz_offset": -25200,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Item status notification",
+ "real_name_normalized": "Item status notification",
+ "display_name": "",
+ "display_name_normalized": "",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "85aa14fc1250",
+ "api_app_id": "A08SZS6B62V",
+ "always_active": true,
+ "image_original": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_original.png",
+ "is_custom_image": true,
+ "bot_id": "B08SZSAHERF",
+ "first_name": "Item",
+ "last_name": "status notification",
+ "image_24": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_24.png",
+ "image_32": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_32.png",
+ "image_48": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_48.png",
+ "image_72": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_72.png",
+ "image_192": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_192.png",
+ "image_512": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_1024.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_admin": false,
+ "is_owner": false,
+ "is_primary_owner": false,
+ "is_restricted": false,
+ "is_ultra_restricted": false,
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1747337592,
+ "is_email_confirmed": false,
+ "who_can_share_contact_card": "EVERYONE",
+ "is_workflow_bot": true
+ },
+ {
+ "id": "U08SZSC6VH7",
+ "team_id": "T2HHNN1FU",
+ "name": "wf_bot_a08szs62zgr",
+ "deleted": false,
+ "color": "bd9336",
+ "real_name": "Welcome message",
+ "tz": "America/Los_Angeles",
+ "tz_label": "Pacific Daylight Time",
+ "tz_offset": -25200,
+ "profile": {
+ "title": "",
+ "phone": "",
+ "skype": "",
+ "real_name": "Welcome message",
+ "real_name_normalized": "Welcome message",
+ "display_name": "",
+ "display_name_normalized": "",
+ "fields": {},
+ "status_text": "",
+ "status_emoji": "",
+ "status_emoji_display_info": [],
+ "status_expiration": 0,
+ "status_clear_on_focus_end": false,
+ "avatar_hash": "ab5acb82d517",
+ "api_app_id": "A08SZS62ZGR",
+ "always_active": true,
+ "image_original": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_original.png",
+ "is_custom_image": true,
+ "bot_id": "B08SZSC2V7T",
+ "first_name": "Welcome",
+ "last_name": "message",
+ "image_24": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_24.png",
+ "image_32": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_32.png",
+ "image_48": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_48.png",
+ "image_72": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_72.png",
+ "image_192": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_192.png",
+ "image_512": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_512.png",
+ "image_1024": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_1024.png",
+ "status_text_canonical": "",
+ "team": "T2HHNN1FU"
+ },
+ "is_admin": false,
+ "is_owner": false,
+ "is_primary_owner": false,
+ "is_restricted": false,
+ "is_ultra_restricted": false,
+ "is_bot": true,
+ "is_app_user": false,
+ "updated": 1747337594,
+ "is_email_confirmed": false,
+ "who_can_share_contact_card": "EVERYONE",
+ "is_workflow_bot": true
+ }
+]
diff --git a/tests/fixtures/mini-export/zoey/2020-04-18.json b/tests/fixtures/mini-export/zoey/2020-04-18.json
new file mode 100644
index 0000000..1f1cb0e
--- /dev/null
+++ b/tests/fixtures/mini-export/zoey/2020-04-18.json
@@ -0,0 +1,159 @@
+[
+ {
+ "subtype": "channel_join",
+ "user": "U2HHNN1HQ",
+ "text": "<@U2HHNN1HQ> has joined the channel",
+ "type": "message",
+ "ts": "1587214815.000200"
+ },
+ {
+ "subtype": "channel_join",
+ "user": "U2J30FCLW",
+ "text": "<@U2J30FCLW> has joined the channel",
+ "inviter": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587214815.000400"
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1587214908.000600",
+ "client_msg_id": "7719b88e-a403-427f-8cd1-2665e5f6d342",
+ "text": "baby!",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "7Zca",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "baby!"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "text": "",
+ "files": [
+ {
+ "id": "F011NH76RUP",
+ "created": 1587214950,
+ "timestamp": 1587214950,
+ "mimetype": "image\/jpeg",
+ "filetype": "jpg",
+ "pretty_type": "JPEG",
+ "user": "U2HHNN1HQ",
+ "user_team": "T2HHNN1FU",
+ "editable": false,
+ "size": 391288,
+ "mode": "hosted",
+ "is_external": false,
+ "external_type": "",
+ "is_public": true,
+ "public_url_shared": false,
+ "display_as_bot": false,
+ "username": "",
+ "name": "IMG_20200418_090219.jpg",
+ "title": "IMG_20200418_090219.jpg",
+ "is_modified_by_ai": false,
+ "url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F011NH76RUP\/img_20200418_090219.jpg?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F011NH76RUP\/download\/img_20200418_090219.jpg?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "media_display_type": "unknown",
+ "thumb_64": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_64.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "thumb_80": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_80.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "thumb_360": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_360.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "thumb_360_w": 360,
+ "thumb_360_h": 270,
+ "thumb_480": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_480.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "thumb_480_w": 480,
+ "thumb_480_h": 360,
+ "thumb_160": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_160.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "thumb_720": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_720.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "thumb_720_w": 720,
+ "thumb_720_h": 540,
+ "thumb_800": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_800.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "thumb_800_w": 800,
+ "thumb_800_h": 600,
+ "thumb_960": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_960.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "thumb_960_w": 960,
+ "thumb_960_h": 720,
+ "thumb_1024": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_1024.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
+ "thumb_1024_w": 1024,
+ "thumb_1024_h": 768,
+ "original_w": 4032,
+ "original_h": 3024,
+ "thumb_tiny": "AwAkADB\/2iQHq1NN1J\/eNRc5IzjPWnBMrnt70APN1IB1bPuKb9sl\/vfpUZJBG75h6U824ZC8bZHv2oAX7bID1B\/Ck+3t6A1W2MzYA5p0qqE+VcEdTnNAEy\/M2B1NSIwCsAMt2BqOIAygE4q35KleRye9AFMBnJ4AHfFOWQ7QqgADnnvU5tyF4Vfriq8YO4gk8dPagCxGpCkkAs5zkCoZYgEYZ59KnaZVQAbmIqpMXZST8q+lAAn+tX61dclQCO9Uk\/1q\/Wrkv3FoAiaVwfvVCGJc5wfwpzdajH3jQAssrghQcD24qu0jH0\/KpJvvfhUNAH\/\/2Q==",
+ "permalink": "https:\/\/levkine.slack.com\/files\/U2HHNN1HQ\/F011NH76RUP\/img_20200418_090219.jpg",
+ "permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F011NH76RUP-993ff9b75a",
+ "is_starred": false,
+ "skipped_shares": true,
+ "has_rich_preview": false,
+ "file_access": "visible"
+ }
+ ],
+ "upload": true,
+ "user": "U2HHNN1HQ",
+ "display_as_bot": false,
+ "type": "message",
+ "ts": "1587214956.000700",
+ "client_msg_id": "e9473154-7c74-4e16-a1ea-aa5855d8648f"
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1587215471.001100",
+ "client_msg_id": "1837b1b9-15d4-41bb-bd82-c4be4d24ce03",
+ "text": "another bublik!",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "beU",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "another bublik!"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/zoey/2020-04-23.json b/tests/fixtures/mini-export/zoey/2020-04-23.json
new file mode 100644
index 0000000..2e5bd44
--- /dev/null
+++ b/tests/fixtures/mini-export/zoey/2020-04-23.json
@@ -0,0 +1,277 @@
+[
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587651542.000200",
+ "client_msg_id": "5e8e082d-4605-4875-a2d6-86607e98e112",
+ "text": "Baby girl names",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587651542.000200",
+ "reply_count": 45,
+ "reply_users_count": 2,
+ "latest_reply": "1728352618.235309",
+ "reply_users": [
+ "U2HHNN1HQ",
+ "U2J30FCLW"
+ ],
+ "replies": [
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1587651713.000300"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1587842605.000600"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1587844690.001400"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1587844880.001600"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1587847411.003900"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1587849379.004800"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1587849408.005000"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1587849458.005200"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1587851865.005400"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1590795033.000100"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1595716889.000100"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1595828802.000600"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1644007596.214229"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1644007715.586559"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1645042618.388199"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1645804390.278629"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1647010205.674939"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1655498003.694859"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1655498021.086939"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1655498026.842429"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1655498036.462279"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1655498116.144589"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1656524709.035089"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1656525039.656139"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1656547897.691709"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1656967670.835969"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1665714316.436109"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1665714329.861469"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1665714395.843509"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1665714406.505149"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1666017144.309979"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1666018571.116679"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1666283311.665639"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1666548905.238439"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1666549084.108529"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1666710283.949469"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1667570827.120389"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1668113332.283759"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1668990306.602409"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1669219368.659929"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1677535858.373999"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1679322325.329009"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1680209837.675889"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1680209876.614319"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1728352618.235309"
+ }
+ ],
+ "is_locked": false,
+ "subscribed": true,
+ "last_read": "1728352618.235309",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "jjC",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Baby girl names"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587651713.000300",
+ "edited": {
+ "user": "U2HHNN1HQ",
+ "ts": "1665714375.000000"
+ },
+ "client_msg_id": "c9bd3422-21c0-423d-830e-030c97e91b02",
+ "text": "Ada\nAlia\nAndi\nAria\/Arya \nDara\nElli\nErin\nEsther\nFadi[e]\nFaye\nFran\nJada\nJane\nJoni\nKira\nMara\nNina\nNora\nRaya\nShai\nYara\nZara\nZoey (Zoya)",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587651542.000200",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "LQG",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Ada\nAlia\nAndi\nAria\/Arya \nDara\nElli\nErin\nEsther\nFadi[e]\nFaye\nFran\nJada\nJane\nJoni\nKira\nMara\nNina\nNora\nRaya\nShai\nYara\nZara\nZoey (Zoya)"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/zoey/2020-04-25.json b/tests/fixtures/mini-export/zoey/2020-04-25.json
new file mode 100644
index 0000000..c4f23bb
--- /dev/null
+++ b/tests/fixtures/mini-export/zoey/2020-04-25.json
@@ -0,0 +1,665 @@
+[
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1587842605.000600",
+ "edited": {
+ "user": "U2J30FCLW",
+ "ts": "1587842643.000000"
+ },
+ "client_msg_id": "614c8f69-b8dc-483e-b559-e26c490e57b4",
+ "text": "Alia\nAndi\nEllie\nErin\nEsther\nFaye\nKira\nMara\nNora\nRaya\nRivka\nShai\nZara\nZoey (Zoya)",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587651542.000200",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "R4c",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Alia\nAndi\nEllie\nErin\nEsther\nFaye\nKira\nMara\nNora\nRaya\nRivka\nShai\nZara\nZoey (Zoya)"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587842804.001100",
+ "client_msg_id": "a48e6885-3191-4df9-ae39-cc6e31939fad",
+ "text": "Baby boy names",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587842804.001100",
+ "reply_count": 30,
+ "reply_users_count": 2,
+ "latest_reply": "1729275913.097429",
+ "reply_users": [
+ "U2HHNN1HQ",
+ "U2J30FCLW"
+ ],
+ "replies": [
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1587844686.001200"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1587847806.004200"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1587847856.004400"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1587848964.004600"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1593312084.002600"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1655497970.128139"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1656550206.616869"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1657383160.915509"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1665713679.801149"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1665713775.915449"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1665713968.370649"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1665714073.390289"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1665772316.640129"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1669042577.074349"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1669134587.534359"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1669817715.842749"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1677534276.857389"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1677534932.624229"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1677537182.790029"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1677537194.748409"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1681397953.547769"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1681573698.769379"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1681573897.480289"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1681673462.733159"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1681673481.251839"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1681673491.808809"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1682375712.953539"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1726097016.108129"
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "ts": "1728352950.566759"
+ },
+ {
+ "user": "U2J30FCLW",
+ "ts": "1729275913.097429"
+ }
+ ],
+ "is_locked": false,
+ "subscribed": true,
+ "last_read": "1729275913.097429",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "0Mn",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Baby boy names"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587844686.001200",
+ "edited": {
+ "user": "U2HHNN1HQ",
+ "ts": "1587847558.000000"
+ },
+ "client_msg_id": "36244a45-572c-4d55-887e-759be38af09a",
+ "text": "Noah\nAbir\nAxel\nAzra\nJonah\nJoni\nSeth\nOzzy\nZach\nUriah\nShai",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587842804.001100",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "i4KW",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Noah\nAbir\nAxel\nAzra\nJonah\nJoni\nSeth\nOzzy\nZach\nUriah\nShai"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587844690.001400",
+ "client_msg_id": "6eb582dc-ea1f-420e-9264-9a7a71baeeb6",
+ "text": "Adie\nJean",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587651542.000200",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "gAvpY",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Adie\nJean"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587844880.001600",
+ "client_msg_id": "112d0d54-1cd8-4812-a5d9-59f6af293603",
+ "text": "Sara",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587651542.000200",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "OHsu",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Sara"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587847411.003900",
+ "client_msg_id": "c10be03a-516a-4d31-9820-1263811fdfaf",
+ "text": "Alvi",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587651542.000200",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "SsyoV",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Alvi"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587847806.004200",
+ "client_msg_id": "886ee1b0-2ae7-4e3b-b59c-d155dc54808e",
+ "text": "Zain",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587842804.001100",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "dgP",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Zain"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587847856.004400",
+ "edited": {
+ "user": "U2HHNN1HQ",
+ "ts": "1669817790.000000"
+ },
+ "client_msg_id": "b6c4a1b6-2a19-4b65-be54-0eff9061b8c1",
+ "text": "Enoch (Nucky)\nEnok",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587842804.001100",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "9uj3M",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Enoch (Nucky)\nEnok"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587848964.004600",
+ "client_msg_id": "7cf7fc44-924f-4dad-ac6a-3e909d26b94c",
+ "text": "Noel",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587842804.001100",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "dpq",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Noel"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587849379.004800",
+ "client_msg_id": "3581817c-3d7a-4708-bc2e-0ece2f173539",
+ "text": "Lucy\nSadie\nNoa",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587651542.000200",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "qD4V",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Lucy\nSadie\nNoa"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1587849408.005000",
+ "client_msg_id": "44d5139e-033f-4a83-bc6b-ffc5bde8edae",
+ "text": "Not a fan of noa. But I do like Lucy and Sadie",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587651542.000200",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "EAVP",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Not a fan of noa. But I do like Lucy and Sadie"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2HHNN1HQ",
+ "type": "message",
+ "ts": "1587849458.005200",
+ "client_msg_id": "2a778fe4-88aa-4b81-a3e6-9515ada836ce",
+ "text": "Alvi?",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gde41167d309",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
+ "first_name": "Ilia",
+ "real_name": "Ilia Dobkin",
+ "display_name": "Ilia",
+ "team": "T2HHNN1FU",
+ "name": "jedi_jew",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587651542.000200",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "PdB",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Alvi?"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1587851865.005400",
+ "client_msg_id": "9a9a0cf0-962f-443d-9458-4af3c299575e",
+ "text": "Not a fan",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587651542.000200",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "dpYcf",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Not a fan"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/mini-export/zoey/2020-05-29.json b/tests/fixtures/mini-export/zoey/2020-05-29.json
new file mode 100644
index 0000000..9276778
--- /dev/null
+++ b/tests/fixtures/mini-export/zoey/2020-05-29.json
@@ -0,0 +1,42 @@
+[
+ {
+ "user": "U2J30FCLW",
+ "type": "message",
+ "ts": "1590795033.000100",
+ "client_msg_id": "c1405527-49a1-49f6-9a14-5e7797adee00",
+ "text": "Ellie\nErin\nEsther\nMara\nShai\nSara\nZoey\nLuct\nSadie",
+ "team": "T2HHNN1FU",
+ "user_team": "T2HHNN1FU",
+ "source_team": "T2HHNN1FU",
+ "user_profile": {
+ "avatar_hash": "gc49a37283df",
+ "image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
+ "first_name": "Irina",
+ "real_name": "Irina",
+ "display_name": "ira_feeds",
+ "team": "T2HHNN1FU",
+ "name": "ira_feeds",
+ "is_restricted": false,
+ "is_ultra_restricted": false
+ },
+ "thread_ts": "1587651542.000200",
+ "parent_user_id": "U2HHNN1HQ",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "dYo",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "Ellie\nErin\nEsther\nMara\nShai\nSara\nZoey\nLuct\nSadie"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/test-manifest.json b/tests/fixtures/test-manifest.json
new file mode 100644
index 0000000..e43f21d
--- /dev/null
+++ b/tests/fixtures/test-manifest.json
@@ -0,0 +1,167 @@
+{
+ "version": 1,
+ "export_path": "/Users/macilia/Documents/code/slack-sieve/tests/fixtures/mini-export",
+ "updated_at": "2026-06-03T16:36:33.190656+00:00",
+ "global_notes": "pytest was here",
+ "channels": {
+ "medical": {
+ "import": true,
+ "status": "partial",
+ "reviewed": true,
+ "import_mode": "pick",
+ "notes": "",
+ "mattermost_channel": "",
+ "mattermost_team": "",
+ "messages": {
+ "medical:test:1": {
+ "action": "import",
+ "checked": true,
+ "files": {},
+ "notes": "",
+ "merge_target": ""
+ }
+ }
+ },
+ "29wilson": {
+ "import": false,
+ "status": "skip",
+ "reviewed": true,
+ "import_mode": "all",
+ "notes": "Returned — not importing",
+ "mattermost_channel": "",
+ "mattermost_team": "",
+ "messages": {}
+ },
+ "zoey": {
+ "import": true,
+ "status": "partial",
+ "reviewed": true,
+ "import_mode": "pick",
+ "notes": "",
+ "mattermost_channel": "",
+ "mattermost_team": "",
+ "messages": {
+ "zoey:test:2": {
+ "action": "import",
+ "checked": true,
+ "files": {},
+ "notes": "",
+ "merge_target": ""
+ }
+ }
+ },
+ "153niagara": {
+ "import": false,
+ "status": "undecided",
+ "reviewed": false,
+ "import_mode": "all",
+ "notes": "",
+ "mattermost_channel": "",
+ "mattermost_team": "",
+ "messages": {
+ "153niagara:1476210563.000002": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1476210563.000003": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1476833882.000005": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1476833895.000006": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1476833908.000007": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1476834078.000008": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1476834088.000009": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1477341477.000002": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1477341924.000003": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1477342010.000004": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1477342047.000005": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1477342051.000006": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1477342059.000007": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1477342065.000008": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ },
+ "153niagara:1477342068.000009": {
+ "action": "inherit",
+ "merge_target": "",
+ "notes": "",
+ "files": {}
+ }
+ }
+ }
+ },
+ "users": {
+ "UTEST": {
+ "import": true,
+ "notes": "mapped",
+ "mattermost_username": "ilia"
+ },
+ "U2HHNN1HQ": {
+ "import": true,
+ "notes": "mapped",
+ "mattermost_username": "testuser"
+ }
+ }
+}
diff --git a/tests/test_auto_cherry_pick_main.py b/tests/test_auto_cherry_pick_main.py
new file mode 100644
index 0000000..1abf061
--- /dev/null
+++ b/tests/test_auto_cherry_pick_main.py
@@ -0,0 +1,49 @@
+"""Integration test for auto-cherry-pick main() on mini-export."""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+REPO = Path(__file__).resolve().parents[1]
+
+
+@pytest.fixture
+def cherry_pick_module():
+ import importlib.util
+
+ path = REPO / "scripts" / "auto-cherry-pick.py"
+ spec = importlib.util.spec_from_file_location("auto_cherry_pick_main", path)
+ mod = importlib.util.module_from_spec(spec)
+ assert spec.loader
+ spec.loader.exec_module(mod)
+ return mod
+
+
+def test_main_dry_run_on_fixture(cherry_pick_module, fixture_export_path, tmp_path, monkeypatch):
+ manifest = tmp_path / "manifest.json"
+ monkeypatch.setattr(cherry_pick_module, "MANIFEST_PATH", manifest)
+ monkeypatch.setattr(cherry_pick_module, "DEFAULT_EXPORT", fixture_export_path)
+ monkeypatch.setattr(sys, "argv", ["auto-cherry-pick.py", "--dry-run"])
+ assert cherry_pick_module.main() == 0
+
+
+def test_main_writes_pick_manifest(cherry_pick_module, fixture_export_path, tmp_path, monkeypatch):
+ manifest = tmp_path / "manifest.json"
+ monkeypatch.setattr(cherry_pick_module, "MANIFEST_PATH", manifest)
+ monkeypatch.setattr(cherry_pick_module, "DEFAULT_EXPORT", fixture_export_path)
+ monkeypatch.setattr(sys, "argv", ["auto-cherry-pick.py"])
+ assert cherry_pick_module.main() == 0
+ data = json.loads(manifest.read_text())
+ picks = sum(
+ 1
+ for ch in data["channels"].values()
+ for m in ch.get("messages", {}).values()
+ if m.get("action") == "import"
+ )
+ assert picks > 0
+ assert "medical" in data["channels"]
+ assert data["channels"]["medical"]["import_mode"] == "pick"
diff --git a/tests/test_cherry_pick.py b/tests/test_cherry_pick.py
new file mode 100644
index 0000000..e655e31
--- /dev/null
+++ b/tests/test_cherry_pick.py
@@ -0,0 +1,82 @@
+"""Unit tests for scripts/auto-cherry-pick.py heuristics."""
+
+from __future__ import annotations
+
+
+def test_return_channels_skipped(cherry_pick, empty_manifest):
+ _, manifest = empty_manifest
+ manifest["channels"]["29wilson"] = {
+ "status": "partial",
+ "messages": {"x": {"action": "import"}},
+ }
+ cherry_pick.reset_all_picks(manifest)
+ entry = manifest["channels"]["29wilson"]
+ assert entry["status"] == "skip"
+ assert entry["messages"] == {}
+
+
+def test_conversation_not_imported(cherry_pick):
+ assert cherry_pick.looks_conversational("should we go with option A?")
+ assert cherry_pick.looks_conversational("sounds good!")
+ assert not cherry_pick.text_is_factual("should we book for Tuesday?")
+ assert cherry_pick.text_is_factual("Call Dr. Smith at 416-555-1234")
+
+
+def test_name_list_detected(cherry_pick):
+ text = "Baby girl names\n• Maya\n• Sarah\n• Leah\n• Naomi"
+ assert cherry_pick.is_name_list_message(text)
+ assert not cherry_pick.is_name_list_message("Zoey had fun at camp today")
+
+
+def test_latest_draft_only_one_winner(cherry_pick, export):
+ msgs, files_map = cherry_pick.build_latest_draft_winners(export, "153niagara")
+ # Multiple draft versions of hot_plate → one message id wins
+ hot = [mid for mid in msgs if "153niagara" in mid]
+ assert len(hot) >= 1
+ for mid, fids in files_map.items():
+ assert mid in msgs
+ assert fids
+
+
+def test_should_import_doc_not_chat(cherry_pick, export):
+ latest, _ = cherry_pick.build_latest_draft_winners(export, "153niagara")
+ picked_docs = 0
+ picked_chat = 0
+ for msg in export.iter_channel_messages("153niagara"):
+ if cherry_pick.should_import(msg, "153niagara", latest_draft_msgs=latest):
+ if msg.get("files"):
+ picked_docs += 1
+ elif (msg.get("text") or "").strip():
+ picked_chat += 1
+ assert picked_docs >= 1
+ # Data-only mode: far fewer bare-text picks than files
+ assert picked_chat <= picked_docs + 5
+
+
+def test_wilson_not_in_auto_channels(cherry_pick):
+ assert "29wilson" not in cherry_pick.AUTO_CHANNELS
+ assert "520steeles" not in cherry_pick.AUTO_CHANNELS
+ assert cherry_pick.RETURN_CHANNELS >= {"29wilson", "520steeles"}
+
+
+def test_dry_run_pick_count(cherry_pick, export, empty_manifest, tmp_path, monkeypatch):
+ path, manifest = empty_manifest
+ monkeypatch.setattr(cherry_pick, "MANIFEST_PATH", tmp_path / "manifest.json")
+ monkeypatch.setattr(cherry_pick, "DEFAULT_EXPORT", export.paths.root)
+
+ import sys
+
+ sys.argv = ["auto-cherry-pick.py", "--dry-run"]
+ # Run pick logic inline
+ cherry_pick.reset_all_picks(manifest)
+ total = 0
+ for ch_name in sorted(cherry_pick.AUTO_CHANNELS):
+ if ch_name in cherry_pick.RETURN_CHANNELS:
+ continue
+ if not export.channel_folder(ch_name):
+ continue
+ latest, _ = cherry_pick.build_latest_draft_winners(export, ch_name)
+ for msg in export.iter_channel_messages(ch_name):
+ if cherry_pick.should_import(msg, ch_name, latest_draft_msgs=latest):
+ total += 1
+ assert 0 < total < 200 # mini fixture stays small
diff --git a/tests/test_cherry_pick_extended.py b/tests/test_cherry_pick_extended.py
new file mode 100644
index 0000000..94b25af
--- /dev/null
+++ b/tests/test_cherry_pick_extended.py
@@ -0,0 +1,63 @@
+"""Additional auto-cherry-pick heuristic coverage."""
+
+from __future__ import annotations
+
+
+def test_informative_image_casual_name(cherry_pick):
+ f = {"name": "MVIMG_20190105.jpg", "mimetype": "image/jpeg"}
+ assert not cherry_pick.informative_image(f, "nice photo")
+ assert cherry_pick.informative_image(
+ f, "Invoice total $1,200 due 416-555-0100"
+ )
+
+
+def test_informative_filename(cherry_pick):
+ assert cherry_pick.informative_filename("lease_agreement.pdf")
+ assert not cherry_pick.informative_filename("IMG_0001.jpg")
+
+
+def test_msg_selection_skips_old_draft(cherry_pick):
+ msg = {
+ "id": "ch:1",
+ "files": [
+ {"id": "D1", "name": "draft_v1.docx"},
+ {"id": "D2", "name": "photo.jpg", "mimetype": "image/jpeg"},
+ ],
+ }
+ sel = cherry_pick.msg_selection(msg, latest_draft_files={"D1"})
+ assert sel["files"].get("D1") is True
+ assert "D2" not in sel["files"] or sel["files"].get("D2") is True # image w/ name
+
+
+def test_draft_group_key_strips_draft_prefix(cherry_pick):
+ key = cherry_pick.draft_group_key("draft_-_hot_plate_v2.docx")
+ assert "draft" not in key.split()[0] or "hot" in key
+ assert "hot" in key.replace("_", " ")
+
+
+def test_text_is_factual_requires_signals(cherry_pick):
+ assert not cherry_pick.text_is_factual("sounds good thanks")
+ assert cherry_pick.text_is_factual("Pay $1,500 to TD account 12345")
+
+
+def test_is_doc_and_image(cherry_pick):
+ assert cherry_pick.is_doc({"name": "x.pdf"})
+ assert cherry_pick.is_image({"name": "x.jpg", "mimetype": "image/jpeg"})
+
+
+def test_channel_entry_skip_shape(cherry_pick):
+ entry = cherry_pick.channel_entry_skip("note")
+ assert entry["status"] == "skip"
+ assert entry["messages"] == {}
+
+
+def test_reset_only_writes(empty_manifest, cherry_pick, tmp_path, monkeypatch):
+ path, _manifest = empty_manifest
+ path.write_text(__import__("json").dumps(_manifest, indent=2) + "\n", encoding="utf-8")
+ monkeypatch.setattr(cherry_pick, "MANIFEST_PATH", path)
+ import sys
+
+ monkeypatch.setattr(sys, "argv", ["auto-cherry-pick.py", "--reset"])
+ assert cherry_pick.main() == 0
+ saved = __import__("json").loads(path.read_text())
+ assert saved["channels"] == {}
diff --git a/tests/test_coverage_gaps.py b/tests/test_coverage_gaps.py
new file mode 100644
index 0000000..70908d2
--- /dev/null
+++ b/tests/test_coverage_gaps.py
@@ -0,0 +1,260 @@
+"""Tests targeting previously uncovered branches."""
+
+from __future__ import annotations
+
+import io
+import json
+import urllib.error
+import urllib.request
+import zipfile
+from pathlib import Path
+import pytest
+
+
+def _status(base: str, path: str, method: str = "GET", body: bytes | None = None):
+ req = urllib.request.Request(
+ f"{base}{path}",
+ data=body,
+ headers={"Content-Type": "application/json"} if body else {},
+ method=method,
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ return resp.status, resp.read()
+ except urllib.error.HTTPError as exc:
+ return exc.code, exc.read()
+
+
+# --- server.py ---
+
+
+def test_file_preview_bad_docx_zip():
+ import server
+
+ out = server.file_preview_payload_bytes(b"PK\x03\x04garbage", "bad.docx")
+ assert out["ok"] is False
+ assert "DOCX" in out["error"] or "Could not" in out["error"]
+
+
+def test_file_preview_quip_html():
+ import server
+
+ html = ''
+ out = server.file_preview_payload_bytes(html.encode(), "doc.quip")
+ assert out["ok"] and "Canvas" in out["html"]
+
+
+def test_static_file_not_found(api):
+ status, _ = _status(api["base"], "/static/no-such-asset.css")
+ assert status == 404
+
+
+def test_uploads_missing_file(api):
+ status, _ = _status(api["base"], "/uploads/__uploads/missing/deadbeef.bin")
+ assert status in (400, 404)
+
+
+def test_file_preview_by_file_id_not_found(api):
+ status, body = _status(api["base"], "/api/file-preview?file_id=UNKNOWN_FILE_XYZ")
+ assert status == 404
+ data = json.loads(body.decode())
+ assert data["ok"] is False
+
+
+def test_file_preview_by_rel_on_disk(api, review_server):
+ rel_dir = review_server["httpd"].RequestHandlerClass.export.paths.root
+ target = rel_dir / "__uploads" / "T1"
+ target.mkdir(parents=True, exist_ok=True)
+ (target / "note.txt").write_text("fixture preview text", encoding="utf-8")
+ status, body = _status(api["base"], "/api/file-preview?rel=__uploads/T1/note.txt")
+ assert status == 200
+ data = json.loads(body.decode())
+ assert data["ok"]
+ assert "fixture preview" in data["html"]
+
+
+def test_manifest_post_empty_body(api):
+ status, _ = _status(
+ api["base"],
+ "/api/manifest",
+ method="POST",
+ body=b"",
+ )
+ assert status == 400
+
+
+def test_manifest_post_wrong_path(api):
+ status, _ = _status(api["base"], "/api/manifest/extra", method="POST", body=b"{}")
+ assert status == 404
+
+
+def test_manifest_post_invalid_import_mode_defaults(api):
+ api["post"](
+ "/api/manifest",
+ {
+ "channels": {
+ "medical": {
+ "status": "partial",
+ "import_mode": "bogus",
+ "messages": {},
+ }
+ }
+ },
+ )
+ med = api["get"]("/api/manifest")["channels"]["medical"]
+ assert med["import_mode"] == "all"
+
+
+def test_manifest_post_skips_non_dict_user(api):
+ users = api["get"]("/api/users")["users"]
+ uid = users[0]["id"]
+ api["post"](
+ "/api/manifest",
+ {"users": {"bad": "nope", uid: {"import": True, "notes": "", "mattermost_username": ""}}},
+ )
+ again = api["get"]("/api/users")["users"]
+ assert any(u["id"] == uid for u in again)
+
+
+def test_safe_export_path_rejects_dotdot():
+ import server
+ from slack_export import SlackExport
+ from unittest.mock import Mock
+
+ root = Path(__file__).resolve().parents[1] / "tests/fixtures/mini-export"
+ handler = Mock(spec=server.ReviewHandler)
+ handler.export = SlackExport(root)
+ assert server.ReviewHandler._safe_export_path(handler, "../outside") is None
+ assert server.ReviewHandler._safe_export_path(handler, "/etc/passwd") is None
+
+
+# --- slack_export.py ---
+
+
+@pytest.fixture
+def rich_export(tmp_path):
+ from slack_export import SlackExport
+
+ root = tmp_path / "rich"
+ root.mkdir()
+ (root / "channels.json").write_text("[]", encoding="utf-8")
+ (root / "users.json").write_text(
+ json.dumps([{"id": "U1", "name": "u", "profile": {"real_name": "U"}}]),
+ encoding="utf-8",
+ )
+ (root / "canvases.json").write_text(
+ json.dumps([{"id": "CV1", "name": "canvas.pdf", "mimetype": "application/pdf"}]),
+ encoding="utf-8",
+ )
+ ch = root / "ch1"
+ ch.mkdir()
+ (ch / "meta.json").write_text('{"not": "a list"}', encoding="utf-8")
+ (ch / "2016-01-01.json").write_text(
+ json.dumps(
+ [
+ {"subtype": "channel_leave", "ts": "1.0"},
+ {"subtype": "channel_purpose", "purpose": "Purpose here", "ts": "2.0"},
+ {"files": [{"id": "NO_NAME"}]},
+ ]
+ ),
+ encoding="utf-8",
+ )
+ return SlackExport(root)
+
+
+def test_file_index_from_canvases_and_skips_bad_rows(rich_export):
+ idx = rich_export.ensure_file_index()
+ assert "CV1" in idx
+ assert rich_export.file_record("NO_NAME") is None
+
+
+def test_fetch_no_download_url(rich_export):
+ rich_export._file_index = {
+ "X1": {
+ "id": "X1",
+ "name": "a.bin",
+ "local_relpath": "__uploads/X1/a.bin",
+ "local_exists": False,
+ "url_private_download": None,
+ }
+ }
+ with pytest.raises(FileNotFoundError, match="No download URL"):
+ rich_export.fetch_file_bytes("X1")
+
+
+def test_fetch_http_error(rich_export, monkeypatch):
+ rich_export._file_index = {
+ "X2": {
+ "id": "X2",
+ "name": "b.bin",
+ "local_relpath": "__uploads/X2/b.bin",
+ "local_exists": False,
+ "url_private_download": "https://example.com/x?token=xoxe-abc",
+ }
+ }
+
+ def _fail(*args, **kwargs):
+ raise urllib.error.HTTPError("url", 401, "Unauthorized", None, None)
+
+ monkeypatch.setattr("urllib.request.urlopen", _fail)
+ with pytest.raises(FileNotFoundError, match="401"):
+ rich_export.fetch_file_bytes("X2")
+
+
+def test_normalize_leave_and_purpose(rich_export):
+ msgs = rich_export.iter_channel_messages("ch1")
+ texts = [m["text"] for m in msgs]
+ assert any("left" in t for t in texts)
+ assert any("Purpose" in t for t in texts)
+
+
+def test_read_non_list_json_returns_empty(rich_export):
+ assert rich_export.read_messages_file("ch1", "meta") == []
+
+
+def test_list_dates_missing_channel(rich_export):
+ assert rich_export.list_channel_date_files("missing-channel") == []
+
+
+def test_search_files_query_filters_out(rich_export):
+ ch = rich_export.paths.root / "hits"
+ ch.mkdir()
+ (ch / "2017-01-01.json").write_text(
+ json.dumps(
+ [
+ {
+ "type": "message",
+ "user": "U1",
+ "ts": "1.0",
+ "text": "alpha",
+ "files": [{"id": "F9", "name": "pic.png", "mimetype": "image/png"}],
+ }
+ ]
+ ),
+ encoding="utf-8",
+ )
+ ex = rich_export
+ out = ex.search_files_global("zzzznomatch", file_type="image", limit=10)
+ assert out["hits"] == []
+
+
+def test_search_global_hits_limit(export):
+ out = export.search_global("a", limit=1)
+ assert len(out["messages"]) <= 1
+
+
+def test_search_messages_stops_at_limit(export):
+ hits = export.search_messages("zoey", "a", limit=2)
+ assert len(hits) <= 2
+
+
+def test_classify_word_doc(export):
+ assert export.classify_file({"name": "x.doc", "mimetype": "application/msword"}) == "doc"
+
+
+def test_register_file_raw_skips_incomplete(export):
+ export._file_index = {}
+ export._register_file_raw({"name": "onlyname"})
+ export._register_file_raw({"id": "idonly"})
+ assert export.file_record("onlyname") is None
+ assert export.file_record("idonly") is None
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
new file mode 100644
index 0000000..530466e
--- /dev/null
+++ b/tests/test_helpers.py
@@ -0,0 +1,113 @@
+"""Tests for small helpers in slack_export and server."""
+
+from __future__ import annotations
+
+import io
+import json
+import zipfile
+from pathlib import Path
+
+import pytest
+
+from slack_export import SlackExport, load_json, resolve_paths, slack_ts_to_iso, user_display
+
+
+def test_slack_ts_to_iso():
+ assert slack_ts_to_iso("1476210563.000002") is not None
+ assert "2016" in slack_ts_to_iso("1476210563.000002")
+ assert slack_ts_to_iso(None) is None
+ assert slack_ts_to_iso("not-a-ts") is None
+
+
+def test_user_display_fallbacks():
+ assert user_display(None) == "unknown"
+ assert user_display({"name": "bot", "is_bot": True}) == "bot"
+ assert user_display({"profile": {"display_name": "Ilia"}, "name": "jedi"}) == "Ilia"
+
+
+def test_resolve_paths_custom_root(fixture_export_path):
+ paths = resolve_paths(fixture_export_path)
+ assert paths.root == fixture_export_path.resolve()
+ assert paths.channels_json.name == "channels.json"
+
+
+def test_load_json_roundtrip(tmp_path):
+ p = tmp_path / "x.json"
+ p.write_text('{"a": 1}', encoding="utf-8")
+ assert load_json(p) == {"a": 1}
+
+
+def test_server_manifest_helpers(tmp_path, fixture_export_path):
+ import server
+
+ path = tmp_path / "m.json"
+ default = server.default_manifest(str(fixture_export_path))
+ assert default["version"] == 1
+
+ loaded = server.load_manifest(path, str(fixture_export_path))
+ assert loaded["export_path"] == str(fixture_export_path)
+
+ path.write_text("{not json", encoding="utf-8")
+ with pytest.raises(json.JSONDecodeError):
+ server.load_manifest(path, str(fixture_export_path))
+
+ server.save_manifest(path, {"version": 1, "channels": {}})
+ assert path.is_file()
+ assert "updated_at" in load_json(path)
+
+
+def test_file_preview_payload_bytes_text_and_html():
+ import server
+
+ txt = server.file_preview_payload_bytes(b"hello world", "notes.txt")
+ assert txt["ok"] and "hello" in txt["html"]
+
+ html = server.file_preview_payload_bytes(
+ b"Title
", "page.html"
+ )
+ assert html["ok"] and html["kind"] == "html"
+
+ bad = server.file_preview_payload_bytes(b"\x00\x01\xff", "binary.dat")
+ assert not bad["ok"]
+
+
+def test_file_preview_payload_bytes_docx(tmp_path):
+ import server
+
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr(
+ "word/document.xml",
+ ''
+ ''
+ "Lease terms",
+ )
+ raw = buf.getvalue()
+ out = server.file_preview_payload_bytes(raw, "lease.docx")
+ assert out["ok"]
+ assert "Lease" in out["html"]
+
+ path = tmp_path / "lease.docx"
+ path.write_bytes(raw)
+ assert "Lease" in server.file_preview_payload(path)["html"]
+
+
+def test_docx_to_html_file(tmp_path):
+ import server
+
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr(
+ "word/document.xml",
+ ''
+ ''
+ "Line one",
+ )
+ path = tmp_path / "t.docx"
+ path.write_bytes(buf.getvalue())
+ assert "Line one" in server.docx_to_html(path)
+
+
+def test_export_missing_root_raises():
+ with pytest.raises(FileNotFoundError):
+ SlackExport("/nonexistent/export/path")
diff --git a/tests/test_prepare_export.py b/tests/test_prepare_export.py
new file mode 100644
index 0000000..695814f
--- /dev/null
+++ b/tests/test_prepare_export.py
@@ -0,0 +1,151 @@
+"""Tests that manifest selections produce a valid filtered Slack export."""
+
+from __future__ import annotations
+
+import json
+import shutil
+import sys
+from pathlib import Path
+
+import pytest
+
+REPO = Path(__file__).resolve().parents[1]
+
+
+def _load_prepare():
+ import importlib.util
+
+ path = REPO / "scripts" / "prepare-export.py"
+ spec = importlib.util.spec_from_file_location("prepare_export", path)
+ mod = importlib.util.module_from_spec(spec)
+ assert spec.loader
+ spec.loader.exec_module(mod)
+ return mod
+
+
+@pytest.fixture
+def prepare_mod():
+ return _load_prepare()
+
+
+def test_channel_included_rules(prepare_mod):
+ assert prepare_mod.channel_included({"status": "import", "import_mode": "all"})
+ assert not prepare_mod.channel_included({"status": "skip"})
+ assert prepare_mod.channel_included(
+ {
+ "status": "partial",
+ "import_mode": "pick",
+ "messages": {"medical:1.0": {"action": "import", "checked": True}},
+ }
+ )
+ assert not prepare_mod.channel_included(
+ {"status": "partial", "import_mode": "pick", "messages": {}}
+ )
+
+
+def test_message_and_files_filter(prepare_mod):
+ entry = {
+ "status": "partial",
+ "import_mode": "pick",
+ "messages": {
+ "ch:1000.1": {
+ "action": "import",
+ "files": {"F1": True, "F2": False},
+ }
+ },
+ }
+ assert prepare_mod.message_included(entry, "ch:1000.1")
+ assert not prepare_mod.message_included(entry, "ch:1000.2")
+ raw = {
+ "ts": "1000.1",
+ "text": "hi",
+ "files": [
+ {"id": "F1", "name": "a.pdf"},
+ {"id": "F2", "name": "b.pdf"},
+ ],
+ }
+ out = prepare_mod.filter_files_on_message(entry, "ch:1000.1", raw)
+ assert len(out["files"]) == 1
+ assert out["files"][0]["id"] == "F1"
+
+
+def test_prepare_export_writes_slack_shape(prepare_mod, fixture_export_path, tmp_path):
+ from slack_export import SlackExport
+
+ export = SlackExport(fixture_export_path)
+ msgs = export.iter_channel_messages("medical")
+ pick = msgs[0]
+ key = pick["id"]
+
+ manifest = {
+ "channels": {
+ "medical": {
+ "status": "partial",
+ "import_mode": "pick",
+ "import": True,
+ "messages": {key: {"action": "import", "checked": True, "files": {}}},
+ }
+ }
+ }
+ out = tmp_path / "prepared"
+ stats = prepare_mod.prepare_export(fixture_export_path, manifest, out, export_mod=export)
+ assert stats["medical"] == 1
+ assert (out / "channels.json").is_file()
+ assert (out / "users.json").is_file()
+ assert (out / "medical").is_dir()
+ day_files = list((out / "medical").glob("*.json"))
+ assert len(day_files) == 1
+ written = json.loads(day_files[0].read_text(encoding="utf-8"))
+ assert len(written) == 1
+ assert written[0]["ts"] == pick["ts"]
+ assert (out / "prepare-export-summary.json").is_file()
+
+
+def test_prepare_whole_channel(prepare_mod, fixture_export_path, tmp_path):
+ from slack_export import SlackExport
+
+ export = SlackExport(fixture_export_path)
+ total = len(export.iter_channel_messages("medical"))
+ manifest = {
+ "channels": {
+ "medical": {
+ "status": "import",
+ "import_mode": "all",
+ "import": True,
+ "messages": {},
+ }
+ }
+ }
+ out = tmp_path / "prepared-all"
+ stats = prepare_mod.prepare_export(fixture_export_path, manifest, out, export_mod=export)
+ assert stats["medical"] == total
+
+
+def test_prepare_skips_channel(prepare_mod, fixture_export_path, tmp_path):
+ from slack_export import SlackExport
+
+ export = SlackExport(fixture_export_path)
+ manifest = {
+ "channels": {
+ "medical": {"status": "skip", "import": False, "messages": {}},
+ "zoey": {
+ "status": "partial",
+ "import_mode": "pick",
+ "import": True,
+ "messages": {
+ export.iter_channel_messages("zoey")[0]["id"]: {
+ "action": "import",
+ "checked": True,
+ }
+ },
+ },
+ }
+ }
+ out = tmp_path / "prepared-skip"
+ stats = prepare_mod.prepare_export(fixture_export_path, manifest, out, export_mod=export)
+ assert "medical" not in stats
+ assert stats["zoey"] == 1
+ meta = json.loads((out / "channels.json").read_text(encoding="utf-8"))
+ names = {c["name"] for c in meta}
+ assert "medical" not in names
+ assert "zoey" in names
diff --git a/tests/test_preset_skips.py b/tests/test_preset_skips.py
new file mode 100644
index 0000000..c5d53a7
--- /dev/null
+++ b/tests/test_preset_skips.py
@@ -0,0 +1,50 @@
+"""Tests for scripts/preset-skips.py."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+from pathlib import Path
+
+import pytest
+
+REPO = Path(__file__).resolve().parents[1]
+
+
+def _load_preset_skips():
+ path = REPO / "scripts" / "preset-skips.py"
+ spec = importlib.util.spec_from_file_location("preset_skips", path)
+ mod = importlib.util.module_from_spec(spec)
+ assert spec.loader
+ spec.loader.exec_module(mod)
+ return mod
+
+
+def test_load_preset_tiers():
+ mod = _load_preset_skips()
+ preset = mod.load_preset(REPO / "presets" / "levkin-skips.json")
+ assert "medical" in preset["skip_all"]
+ assert "zoey" in preset["review_priority"]
+ assert "29wilson" in preset["tier_b"]
+
+
+def test_channel_entry_skip():
+ mod = _load_preset_skips()
+ entry = mod.channel_entry_skip("tier-a")
+ assert entry["status"] == "skip"
+ assert entry["reviewed"] is True
+
+
+def test_main_writes_manifest(tmp_path, fixture_export_path, monkeypatch):
+ mod = _load_preset_skips()
+ manifest_path = tmp_path / "manifest.json"
+ monkeypatch.setattr(mod, "MANIFEST_PATH", manifest_path)
+ monkeypatch.setattr(mod, "DEFAULT_EXPORT", fixture_export_path)
+
+ import sys
+
+ sys.argv = ["preset-skips.py", str(fixture_export_path)]
+ assert mod.main() == 0
+ data = json.loads(manifest_path.read_text())
+ assert data["channels"]["medical"]["status"] == "skip"
+ assert "zoey" not in data["channels"] or data["channels"].get("zoey", {}).get("status") != "skip"
diff --git a/tests/test_server.py b/tests/test_server.py
new file mode 100644
index 0000000..2ede8aa
--- /dev/null
+++ b/tests/test_server.py
@@ -0,0 +1,112 @@
+"""HTTP API tests against the review server (mini-export)."""
+
+from __future__ import annotations
+
+import json
+
+
+def test_health(api):
+ data = api["get"]("/api/health")
+ assert data["ok"] is True
+ assert "mini-export" in data["export_path"]
+
+
+def test_summary(api):
+ data = api["get"]("/api/summary")
+ assert data["channel_count"] >= 3
+ assert data["total_messages"] > 0
+
+
+def test_channels_list(api):
+ data = api["get"]("/api/channels")
+ names = {c["name"] for c in data["channels"]}
+ assert "medical" in names
+ med = next(c for c in data["channels"] if c["name"] == "medical")
+ assert med["status"] == "partial"
+ assert med["import_mode"] == "pick"
+
+
+def test_channel_messages_all(api):
+ data = api["get"]("/api/channels/medical/messages/all")
+ assert data["channel"] == "medical"
+ assert data["total"] == len(data["messages"])
+ assert data["total"] > 0
+
+
+def test_search_messages(api):
+ data = api["get"]("/api/search?q=doctor&limit=10")
+ assert "messages" in data
+
+
+def test_search_files(api):
+ data = api["get"]("/api/search-files?type=pdf&q=&limit=10")
+ assert data["file_type"] == "pdf"
+ assert "hits" in data
+
+
+def test_manifest_roundtrip(api):
+ got = api["get"]("/api/manifest")
+ assert "channels" in got
+ got["global_notes"] = "pytest was here"
+ api["post"]("/api/manifest", {"global_notes": "pytest was here", "channels": got["channels"], "users": {}})
+ again = api["get"]("/api/manifest")
+ assert again["global_notes"] == "pytest was here"
+
+
+def test_manifest_merge_preserves_other_channel_picks(api):
+ api["post"](
+ "/api/manifest",
+ {
+ "channels": {
+ "medical": {
+ "import": True,
+ "status": "partial",
+ "import_mode": "pick",
+ "reviewed": True,
+ "messages": {
+ "medical:test:1": {
+ "action": "import",
+ "checked": True,
+ "files": {},
+ "notes": "",
+ "merge_target": "",
+ }
+ },
+ }
+ }
+ },
+ )
+ api["post"](
+ "/api/manifest",
+ {
+ "channels": {
+ "zoey": {
+ "import": True,
+ "status": "partial",
+ "import_mode": "pick",
+ "reviewed": True,
+ "messages": {
+ "zoey:test:2": {
+ "action": "import",
+ "checked": True,
+ "files": {},
+ "notes": "",
+ "merge_target": "",
+ }
+ },
+ }
+ }
+ },
+ )
+ merged = api["get"]("/api/manifest")
+ assert "medical:test:1" in merged["channels"]["medical"]["messages"]
+ assert "zoey:test:2" in merged["channels"]["zoey"]["messages"]
+
+
+def test_static_index(api):
+ import urllib.request
+
+ with urllib.request.urlopen(f"{api['base']}/", timeout=5) as resp:
+ html = resp.read().decode()
+ assert "Slack import review" in html
+ assert "filter-status" in html
diff --git a/tests/test_server_extended.py b/tests/test_server_extended.py
new file mode 100644
index 0000000..d5e203d
--- /dev/null
+++ b/tests/test_server_extended.py
@@ -0,0 +1,162 @@
+"""Additional HTTP API and server module coverage."""
+
+from __future__ import annotations
+
+import json
+import urllib.error
+import urllib.request
+
+import pytest
+
+
+def _get_raw(base: str, path: str) -> tuple[int, bytes]:
+ try:
+ with urllib.request.urlopen(f"{base}{path}", timeout=5) as resp:
+ return resp.status, resp.read()
+ except urllib.error.HTTPError as exc:
+ return exc.code, exc.read()
+
+
+def test_users_list(api):
+ data = api["get"]("/api/users")
+ assert isinstance(data["users"], list)
+ if data["users"]:
+ assert "id" in data["users"][0]
+
+
+def test_channel_search_endpoint(api):
+ data = api["get"]("/api/channels/medical/search?q=doctor")
+ assert data["channel"] == "medical"
+ assert isinstance(data["hits"], list)
+
+
+def test_channel_messages_paged(api):
+ data = api["get"]("/api/channels/zoey/messages?limit=3&offset=0")
+ assert data["channel"] == "zoey"
+ assert len(data["messages"]) <= 3
+ assert "dates" in data
+
+
+def test_channels_filter_query(api):
+ data = api["get"]("/api/channels?q=med")
+ names = {c["name"] for c in data["channels"]}
+ assert "medical" in names
+
+
+def test_file_preview_missing_rel(api):
+ status, body = _get_raw(api["base"], "/api/file-preview?rel=../outside.txt")
+ assert status == 404
+ data = json.loads(body.decode())
+ assert data["ok"] is False
+
+
+def test_file_preview_traversal_blocked(api):
+ status, _ = _get_raw(api["base"], "/api/file-preview?rel=..%2F..%2Fetc%2Fpasswd")
+ assert status in (400, 404)
+
+
+def test_file_download_bad_id(api):
+ status, _ = _get_raw(api["base"], "/api/file-download?file_id=NOPE")
+ assert status == 404
+
+
+def test_file_download_missing_param(api):
+ status, _ = _get_raw(api["base"], "/api/file-download")
+ assert status == 400
+
+
+def test_uploads_path_traversal(api):
+ status, _ = _get_raw(api["base"], "/uploads/..%2F..%2Fetc%2Fpasswd")
+ assert status == 400
+
+
+def test_static_css(api):
+ status, body = _get_raw(api["base"], "/static/app.css")
+ assert status == 200
+ assert b"message" in body or len(body) > 0
+
+
+def test_options_cors(api):
+ req = urllib.request.Request(
+ f"{api['base']}/api/manifest",
+ method="OPTIONS",
+ )
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ assert resp.status == 204
+ assert resp.headers.get("Access-Control-Allow-Origin") == "*"
+
+
+def test_manifest_invalid_json(api):
+ req = urllib.request.Request(
+ f"{api['base']}/api/manifest",
+ data=b"{not valid json",
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ with pytest.raises(urllib.error.HTTPError) as exc:
+ urllib.request.urlopen(req, timeout=5)
+ assert exc.value.code == 400
+
+
+def test_not_found_route(api):
+ status, _ = _get_raw(api["base"], "/api/does-not-exist")
+ assert status == 404
+
+
+def test_manifest_post_users(api):
+ users_before = api["get"]("/api/users")["users"]
+ assert users_before
+ uid = users_before[0]["id"]
+ api["post"](
+ "/api/manifest",
+ {
+ "users": {
+ uid: {
+ "import": True,
+ "notes": "mapped",
+ "mattermost_username": "testuser",
+ }
+ }
+ },
+ )
+ users = api["get"]("/api/users")["users"]
+ match = next((u for u in users if u["id"] == uid), None)
+ assert match and match["import"] and match["mattermost_username"] == "testuser"
+
+
+def test_manifest_post_invalid_channel_entry_skipped(api):
+ api["post"](
+ "/api/manifest",
+ {"channels": {"bad": "not-a-dict", "medical": {"status": "partial", "import_mode": "pick"}}},
+ )
+ med = api["get"]("/api/manifest")["channels"]["medical"]
+ assert med["status"] == "partial"
+
+
+def test_file_download_local_upload(api, tmp_path, review_server):
+ """Download endpoint serves bytes when upload is on disk."""
+ import sys
+
+ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1]))
+ from slack_export import SlackExport
+
+ root = tmp_path / "dl_export"
+ root.mkdir()
+ (root / "channels.json").write_text("[]", encoding="utf-8")
+ (root / "users.json").write_text("[]", encoding="utf-8")
+ up = root / "__uploads" / "FID1"
+ up.mkdir(parents=True)
+ (up / "doc.pdf").write_bytes(b"%PDF-1.4")
+ ex = SlackExport(root)
+ ex.ensure_file_index()
+ ex._file_index["FID1"] = {
+ "id": "FID1",
+ "name": "doc.pdf",
+ "local_relpath": "__uploads/FID1/doc.pdf",
+ "local_exists": True,
+ "url_private_download": None,
+ }
+ review_server["httpd"].RequestHandlerClass.export = ex
+ status, body = _get_raw(api["base"], "/api/file-download?file_id=FID1")
+ assert status == 200
+ assert body.startswith(b"%PDF")
diff --git a/tests/test_slack_export.py b/tests/test_slack_export.py
new file mode 100644
index 0000000..0298b08
--- /dev/null
+++ b/tests/test_slack_export.py
@@ -0,0 +1,58 @@
+"""Unit tests for slack_export.py (mini-export fixture)."""
+
+from __future__ import annotations
+
+
+def test_fixture_has_expected_channels(export):
+ names = {c["name"] for c in export.build_summary()["channels"]}
+ assert names >= {"medical", "zoey", "153niagara"}
+
+
+def test_normalize_message_shape(export):
+ msgs = export.iter_channel_messages("medical")
+ msg = msgs[0]
+ assert "id" in msg
+ assert "channel" in msg
+ assert msg["channel"] == "medical"
+ assert "user_name" in msg
+ assert "text" in msg
+
+
+def test_classify_file_types(export):
+ assert export.classify_file({"name": "lease.pdf", "mimetype": "application/pdf"}) == "pdf"
+ assert export.classify_file({"name": "photo.jpg", "mimetype": "image/jpeg"}) == "image"
+ assert export.classify_file({"name": "data.xlsx", "mimetype": ""}) == "spreadsheet"
+
+
+def test_search_channels(export):
+ hits = export.search_channels("med")
+ assert any(c["name"] == "medical" for c in hits)
+ assert not any(c["name"] == "zoey" for c in hits)
+
+
+def test_search_global_text(export):
+ result = export.search_global("doctor", limit=20)
+ assert result["query"] == "doctor"
+ assert isinstance(result["messages"], list)
+
+
+def test_search_files_global_images(export):
+ result = export.search_files_global("", file_type="image", limit=30)
+ assert result["file_type"] == "image"
+ for hit in result["hits"]:
+ kinds = set(hit.get("file_kinds") or [])
+ assert "image" in kinds
+
+
+def test_channel_messages_all_sorted(export):
+ data = export.channel_messages_all("zoey")
+ assert data["channel"] == "zoey"
+ assert data["total"] == len(data["messages"])
+ times = [m.get("time") or "" for m in data["messages"]]
+ assert times == sorted(times)
+
+
+def test_upload_path_safe(export):
+ rel = export.upload_relpath("F123", "my file.pdf")
+ assert rel == "__uploads/F123/my file.pdf"
+ assert ".." not in rel
diff --git a/tests/test_slack_export_extended.py b/tests/test_slack_export_extended.py
new file mode 100644
index 0000000..edd73ac
--- /dev/null
+++ b/tests/test_slack_export_extended.py
@@ -0,0 +1,203 @@
+"""Additional slack_export coverage — normalize, index, pagination, search."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from slack_export import SlackExport
+
+
+@pytest.fixture
+def export_with_synthetic(tmp_path, fixture_export_path):
+ """Mini export plus synthetic message edge cases."""
+ ch = tmp_path / "synthetic"
+ ch.mkdir()
+ (tmp_path / "channels.json").write_text(
+ json.dumps([{"name": "synthetic", "id": "C1"}]),
+ encoding="utf-8",
+ )
+ (tmp_path / "users.json").write_text(
+ json.dumps(
+ [
+ {
+ "id": "U1",
+ "name": "ilia",
+ "profile": {"display_name": "Ilia", "real_name": "Ilia L"},
+ }
+ ]
+ ),
+ encoding="utf-8",
+ )
+ messages = [
+ {"type": "message", "user": "U1", "ts": "1000.1", "subtype": "channel_join"},
+ {
+ "type": "message",
+ "user": "U1",
+ "ts": "1000.2",
+ "subtype": "channel_topic",
+ "topic": "Rentals",
+ },
+ {
+ "type": "message",
+ "user": "U1",
+ "ts": "1000.3",
+ "subtype": "file_share",
+ "files": [{"id": "F1", "name": "invoice.pdf", "mimetype": "application/pdf"}],
+ },
+ {
+ "type": "message",
+ "user": "U1",
+ "ts": "1000.4",
+ "thread_ts": "1000.4",
+ "reply_count": 2,
+ "text": "Thread root",
+ },
+ {
+ "type": "message",
+ "user": "U1",
+ "ts": "1000.5",
+ "thread_ts": "1000.4",
+ "text": "Reply",
+ },
+ {"type": "message", "bot_id": "B1", "ts": "1000.6", "text": "bot says hi"},
+ {"type": "message", "user": "U1", "ts": "1000.7", "blocks": [{"type": "section"}]},
+ {"type": "message", "user": "U1", "ts": "1000.8", "attachments": [{"text": "card"}]},
+ ]
+ (ch / "2016-10-10.json").write_text(json.dumps(messages), encoding="utf-8")
+ return SlackExport(tmp_path)
+
+
+def test_classify_all_file_kinds(export):
+ assert export.classify_file({"name": "x.pptx"}) == "slides"
+ assert export.classify_file({"name": "x.mp4", "mimetype": "video/mp4"}) == "video"
+ assert export.classify_file({"name": "x.mp3"}) == "audio"
+ assert export.classify_file({"name": "x.zip"}) == "archive"
+ assert export.classify_file({"name": "readme.md"}) == "text"
+ assert export.classify_file({"name": "unknown.bin"}) == "other"
+
+
+def test_normalize_message_subtypes(export_with_synthetic):
+ msgs = export_with_synthetic.iter_channel_messages("synthetic")
+ by_ts = {m["ts"]: m for m in msgs}
+ assert "joined" in by_ts["1000.1"]["text"]
+ assert "Rentals" in by_ts["1000.2"]["text"]
+ assert "invoice.pdf" in by_ts["1000.3"]["text"]
+ assert by_ts["1000.5"]["is_thread_reply"]
+ assert by_ts["1000.4"]["thread_reply_count"] >= 1
+ assert by_ts["1000.6"]["is_bot"]
+ assert by_ts["1000.7"]["text"] == "[rich message]"
+ assert by_ts["1000.8"]["text"] == "[attachment]"
+
+
+def test_ensure_file_index(export_with_synthetic):
+ idx = export_with_synthetic.ensure_file_index()
+ assert "F1" in idx
+ assert idx["F1"]["name"] == "invoice.pdf"
+
+
+def test_file_record_unknown(export):
+ assert export.file_record("NONEXISTENT_ID_XYZ") is None
+
+
+def test_channel_messages_page_pagination(export):
+ page = export.channel_messages_page("zoey", limit=2, offset=0)
+ assert page["channel"] == "zoey"
+ assert len(page["messages"]) <= 2
+ assert page["total_in_date"] >= len(page["messages"])
+ if page["has_more"]:
+ page2 = export.channel_messages_page("zoey", limit=2, offset=2)
+ assert page2["offset"] == 2
+
+
+def test_channel_messages_page_empty_channel(tmp_path):
+ empty = tmp_path / "empty_export"
+ empty.mkdir()
+ (empty / "channels.json").write_text("[]", encoding="utf-8")
+ (empty / "users.json").write_text("[]", encoding="utf-8")
+ (empty / "ghost").mkdir()
+ ex = SlackExport(empty)
+ out = ex.channel_messages_page("ghost")
+ assert out["messages"] == []
+ assert out["has_more"] is False
+
+
+def test_search_messages_channel_scoped(export):
+ hits = export.search_messages("medical", "doctor", limit=5)
+ assert isinstance(hits, list)
+ assert export.search_messages("medical", "", limit=5) == []
+
+
+def test_search_global_empty_query(export):
+ out = export.search_global(" ", limit=5)
+ assert out["channels"] == []
+ assert out["messages"] == []
+
+
+def test_search_global_channel_name(export):
+ out = export.search_global("niagara", limit=10)
+ assert any(c["name"] == "153niagara" for c in out["channels"])
+
+
+def test_search_files_with_query(export):
+ out = export.search_files_global("proof", file_type="all", limit=10)
+ assert "hits" in out
+
+
+def test_channel_stats_missing(export):
+ stats = export.channel_stats("no-such-channel-xyz")
+ assert stats["exists_in_export"] is False
+ assert stats["message_count"] == 0
+
+
+def test_read_messages_invalid_date(export):
+ assert export.read_messages_file("medical", "2099-99-99") == []
+
+
+def test_attach_thread_reply_counts(export_with_synthetic):
+ msgs = export_with_synthetic.iter_channel_messages("synthetic")
+ assert any(m.get("thread_reply_count", 0) > 0 for m in msgs)
+
+
+def test_fetch_file_bytes_local(tmp_path, export_with_synthetic):
+ rec = export_with_synthetic.file_record("F1")
+ assert rec
+ local = export_with_synthetic.paths.root / rec["local_relpath"]
+ local.parent.mkdir(parents=True, exist_ok=True)
+ local.write_bytes(b"%PDF-1.4 test")
+ data, meta = export_with_synthetic.fetch_file_bytes("F1")
+ assert data.startswith(b"%PDF")
+ assert meta["id"] == "F1"
+
+
+def test_fetch_file_bytes_remote(export_with_synthetic, monkeypatch):
+ rec = export_with_synthetic.file_record("F1")
+ local = export_with_synthetic.paths.root / rec["local_relpath"]
+ if local.is_file():
+ local.unlink()
+
+ class _Resp:
+ def read(self):
+ return b"remote-bytes"
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ pass
+
+ monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _Resp())
+ rec_entry = export_with_synthetic.ensure_file_index()["F1"]
+ rec_entry["url_private_download"] = "https://files.slack.com/x?token=xoxe-test"
+
+ data, meta = export_with_synthetic.fetch_file_bytes("F1")
+ assert data == b"remote-bytes"
+ assert local.is_file()
+ assert meta["local_exists"] is True
+
+
+def test_fetch_file_bytes_unknown_id(export):
+ with pytest.raises(FileNotFoundError, match="Unknown file"):
+ export.fetch_file_bytes("NO_SUCH_FILE")