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 = '

Canvas

' + 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")