Add triage tests, prepare-export, and UI fixes for Levkin migration.
Cherry-pick heuristics, manifest merge, global file search, scrollbar styling, and pytest/Playwright coverage unblock review and filtered export for mmetl.
This commit is contained in:
parent
88b7082f4f
commit
4b034d19f6
16
.coveragerc
Normal file
16
.coveragerc
Normal file
@ -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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -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/
|
||||
|
||||
35
Makefile
35
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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
76
package-lock.json
generated
Normal file
76
package-lock.json
generated
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
package.json
Normal file
12
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
36
playwright.config.ts
Normal file
36
playwright.config.ts
Normal file
@ -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}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
2
requirements-dev.txt
Normal file
2
requirements-dev.txt
Normal file
@ -0,0 +1,2 @@
|
||||
pytest>=8.0
|
||||
pytest-cov>=4.0
|
||||
439
scripts/auto-cherry-pick.py
Normal file
439
scripts/auto-cherry-pick.py
Normal file
@ -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())
|
||||
59
scripts/build-test-fixture.py
Normal file
59
scripts/build-test-fixture.py
Normal file
@ -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())
|
||||
223
scripts/prepare-export.py
Normal file
223
scripts/prepare-export.py
Normal file
@ -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())
|
||||
29
server.py
29
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:
|
||||
|
||||
103
slack_export.py
103
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"]
|
||||
|
||||
103
static/app.css
103
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);
|
||||
|
||||
429
static/app.js
429
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 = `
|
||||
<span class="status-dot ${dotStatus}"></span>
|
||||
<div class="body">
|
||||
@ -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 = "<label>Attachments</label>";
|
||||
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('<div class="search-group"><h4>Channels</h4>');
|
||||
for (const ch of data.channels.slice(0, 12)) {
|
||||
parts.push(
|
||||
`<button type="button" class="search-hit" data-channel="${escapeHtml(ch.name)}">#${escapeHtml(ch.name)}</button>`
|
||||
);
|
||||
|
||||
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(`<div class="search-group"><h4>${escapeHtml(label)}</h4>`);
|
||||
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(
|
||||
`<button type="button" class="search-hit" data-channel="${escapeHtml(m.channel)}" data-msg="${escapeHtml(m.id)}">
|
||||
<strong>#${escapeHtml(m.channel)}</strong> · ${escapeHtml(m.user_name)}
|
||||
<div class="sub">${escapeHtml(names || (m.text || "").slice(0, 80))}</div>
|
||||
</button>`
|
||||
);
|
||||
}
|
||||
parts.push("</div>");
|
||||
}
|
||||
parts.push("</div>");
|
||||
}
|
||||
if (data.messages?.length) {
|
||||
parts.push('<div class="search-group"><h4>Messages</h4>');
|
||||
for (const m of data.messages.slice(0, 20)) {
|
||||
parts.push(
|
||||
`<button type="button" class="search-hit" data-channel="${escapeHtml(m.channel)}" data-msg="${escapeHtml(m.id)}">
|
||||
<strong>#${escapeHtml(m.channel)}</strong> · ${escapeHtml(m.user_name)}
|
||||
<div class="sub">${escapeHtml((m.text || "").slice(0, 100))}</div>
|
||||
</button>`
|
||||
);
|
||||
} else {
|
||||
const data = await api(`/api/search?q=${encodeURIComponent(query)}&limit=60`);
|
||||
if (data.channels?.length) {
|
||||
parts.push('<div class="search-group"><h4>Channels</h4>');
|
||||
for (const ch of data.channels.slice(0, 12)) {
|
||||
parts.push(
|
||||
`<button type="button" class="search-hit" data-channel="${escapeHtml(ch.name)}">#${escapeHtml(ch.name)}</button>`
|
||||
);
|
||||
}
|
||||
parts.push("</div>");
|
||||
}
|
||||
if (data.messages?.length) {
|
||||
parts.push('<div class="search-group"><h4>Messages</h4>');
|
||||
for (const m of data.messages.slice(0, 20)) {
|
||||
parts.push(
|
||||
`<button type="button" class="search-hit" data-channel="${escapeHtml(m.channel)}" data-msg="${escapeHtml(m.id)}">
|
||||
<strong>#${escapeHtml(m.channel)}</strong> · ${escapeHtml(m.user_name)}
|
||||
<div class="sub">${escapeHtml((m.text || "").slice(0, 100))}</div>
|
||||
</button>`
|
||||
);
|
||||
}
|
||||
parts.push("</div>");
|
||||
}
|
||||
parts.push("</div>");
|
||||
}
|
||||
|
||||
els.globalSearchResults.innerHTML =
|
||||
parts.join("") || '<div class="empty" style="padding:1rem">No results</div>';
|
||||
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();
|
||||
|
||||
@ -12,12 +12,29 @@
|
||||
<h1>Slack import review</h1>
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="global-search-wrap">
|
||||
<input
|
||||
type="search"
|
||||
id="global-search"
|
||||
placeholder="Search channels & messages… (press /)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="global-search-row">
|
||||
<select id="global-search-mode" title="Search scope">
|
||||
<option value="text">Text</option>
|
||||
<option value="files">Files (all channels)</option>
|
||||
</select>
|
||||
<select id="global-file-type" class="hidden" title="Attachment type">
|
||||
<option value="all">All types</option>
|
||||
<option value="image">Images</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="doc">Word</option>
|
||||
<option value="spreadsheet">Spreadsheets</option>
|
||||
<option value="slides">Slides</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="text">Text files</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<input
|
||||
type="search"
|
||||
id="global-search"
|
||||
placeholder="Search channels & messages… (press /)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-results hidden" id="global-search-results"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@ -33,7 +50,7 @@
|
||||
<option value="all">All</option>
|
||||
<option value="undecided">Undecided</option>
|
||||
<option value="import">Import</option>
|
||||
<option value="partial">Cherry-pick</option>
|
||||
<option value="partial">Has picks</option>
|
||||
<option value="skip">Skip</option>
|
||||
</select>
|
||||
<label class="chk"><input type="checkbox" id="filter-hide-archived" /> Hide archived</label>
|
||||
@ -73,6 +90,16 @@
|
||||
<span class="msg-nav-status" id="msg-nav-status">—</span>
|
||||
</div>
|
||||
<input type="search" id="message-search" placeholder="Filter messages…" />
|
||||
<select id="filter-attachment-type" title="Show messages with this attachment type">
|
||||
<option value="all">All msgs</option>
|
||||
<option value="any">Has any file</option>
|
||||
<option value="image">Has image</option>
|
||||
<option value="pdf">Has PDF</option>
|
||||
<option value="doc">Has doc</option>
|
||||
<option value="spreadsheet">Has spreadsheet</option>
|
||||
</select>
|
||||
<button type="button" class="btn-mini" id="btn-select-images" title="Toggle import for visible image messages">Select images in view</button>
|
||||
<button type="button" class="btn-mini" id="btn-select-files" title="Toggle import for visible file attachments">Select files in view</button>
|
||||
<label class="chk"><input type="checkbox" id="filter-hide-bots" /> Hide bots</label>
|
||||
<label class="chk"><input type="checkbox" id="filter-hide-system" checked /> Hide system</label>
|
||||
<label class="chk"><input type="checkbox" id="filter-only-selected" /> Only selected</label>
|
||||
|
||||
69
tests/README.md
Normal file
69
tests/README.md
Normal file
@ -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**.
|
||||
138
tests/conftest.py
Normal file
138
tests/conftest.py
Normal file
@ -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}
|
||||
139
tests/e2e/review-ui.spec.ts
Normal file
139
tests/e2e/review-ui.spec.ts
Normal file
@ -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 });
|
||||
});
|
||||
});
|
||||
17
tests/fixtures/mini-export/153niagara/2016-10-05.json
vendored
Normal file
17
tests/fixtures/mini-export/153niagara/2016-10-05.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
198
tests/fixtures/mini-export/153niagara/2016-10-11.json
vendored
Normal file
198
tests/fixtures/mini-export/153niagara/2016-10-11.json
vendored
Normal file
@ -0,0 +1,198 @@
|
||||
[
|
||||
{
|
||||
"text": "<@U2HHNN1HQ|jedi_jew> shared a file: <https:\/\/levkine.slack.com\/files\/jedi_jew\/F2N37HDQA\/Proof_read|Proof read>",
|
||||
"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 <https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF|https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF>: 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 <https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF|https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF>: You can proof read in the post"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"type": "message",
|
||||
"ts": "1476210563.000003"
|
||||
}
|
||||
]
|
||||
309
tests/fixtures/mini-export/153niagara/2016-10-18.json
vendored
Normal file
309
tests/fixtures/mini-export/153niagara/2016-10-18.json
vendored
Normal file
@ -0,0 +1,309 @@
|
||||
[
|
||||
{
|
||||
"text": "<@U2J30FCLW|ira_feeds> shared a file: <https:\/\/levkine.slack.com\/files\/ira_feeds\/F2R3Y6L9L\/draft_-_hot_plate|draft - hot plate>",
|
||||
"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 <https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN|https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN>: 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 <https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN|https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN>: perfect"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"type": "message",
|
||||
"ts": "1476834088.000009"
|
||||
}
|
||||
]
|
||||
298
tests/fixtures/mini-export/153niagara/2016-10-24.json
vendored
Normal file
298
tests/fixtures/mini-export/153niagara/2016-10-24.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
BIN
tests/fixtures/mini-export/__uploads/F011NH76RUP/IMG_20200418_090219.jpg
vendored
Normal file
BIN
tests/fixtures/mini-export/__uploads/F011NH76RUP/IMG_20200418_090219.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 382 KiB |
1
tests/fixtures/mini-export/__uploads/T1/note.txt
vendored
Normal file
1
tests/fixtures/mini-export/__uploads/T1/note.txt
vendored
Normal file
@ -0,0 +1 @@
|
||||
fixture preview text
|
||||
68
tests/fixtures/mini-export/channels.json
vendored
Normal file
68
tests/fixtures/mini-export/channels.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
17
tests/fixtures/mini-export/medical/2016-10-05.json
vendored
Normal file
17
tests/fixtures/mini-export/medical/2016-10-05.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
261
tests/fixtures/mini-export/medical/2016-10-10.json
vendored
Normal file
261
tests/fixtures/mini-export/medical/2016-10-10.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
241
tests/fixtures/mini-export/medical/2016-10-27.json
vendored
Normal file
241
tests/fixtures/mini-export/medical/2016-10-27.json
vendored
Normal file
@ -0,0 +1,241 @@
|
||||
[
|
||||
{
|
||||
"user": "U2J30FCLW",
|
||||
"type": "message",
|
||||
"ts": "1477621049.000002",
|
||||
"text": "<https:\/\/www.tweed.com\/collections\/available\/products\/ocean-view-leafs-by-snoop>",
|
||||
"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": "<https:\/\/www.tweed.com\/collections\/available\/products\/palm-tree-cbd-leafs-by-snoop>",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
84
tests/fixtures/mini-export/medical/2018-09-13.json
vendored
Normal file
84
tests/fixtures/mini-export/medical/2018-09-13.json
vendored
Normal file
@ -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?"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
933
tests/fixtures/mini-export/users.json
vendored
Normal file
933
tests/fixtures/mini-export/users.json
vendored
Normal file
@ -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
|
||||
}
|
||||
]
|
||||
159
tests/fixtures/mini-export/zoey/2020-04-18.json
vendored
Normal file
159
tests/fixtures/mini-export/zoey/2020-04-18.json
vendored
Normal file
@ -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!"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
277
tests/fixtures/mini-export/zoey/2020-04-23.json
vendored
Normal file
277
tests/fixtures/mini-export/zoey/2020-04-23.json
vendored
Normal file
@ -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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
665
tests/fixtures/mini-export/zoey/2020-04-25.json
vendored
Normal file
665
tests/fixtures/mini-export/zoey/2020-04-25.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
42
tests/fixtures/mini-export/zoey/2020-05-29.json
vendored
Normal file
42
tests/fixtures/mini-export/zoey/2020-05-29.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
167
tests/fixtures/test-manifest.json
vendored
Normal file
167
tests/fixtures/test-manifest.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
49
tests/test_auto_cherry_pick_main.py
Normal file
49
tests/test_auto_cherry_pick_main.py
Normal file
@ -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"
|
||||
82
tests/test_cherry_pick.py
Normal file
82
tests/test_cherry_pick.py
Normal file
@ -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
|
||||
63
tests/test_cherry_pick_extended.py
Normal file
63
tests/test_cherry_pick_extended.py
Normal file
@ -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"] == {}
|
||||
260
tests/test_coverage_gaps.py
Normal file
260
tests/test_coverage_gaps.py
Normal file
@ -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 = '<div class="quip-canvas-content"><p>Canvas</p></div>'
|
||||
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
|
||||
113
tests/test_helpers.py
Normal file
113
tests/test_helpers.py
Normal file
@ -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"<html><body><h1>Title</h1></body></html>", "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",
|
||||
'<?xml version="1.0"?>'
|
||||
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
"<w:p><w:r><w:t>Lease terms</w:t></w:r></w:p></w:document>",
|
||||
)
|
||||
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",
|
||||
'<?xml version="1.0"?>'
|
||||
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
"<w:p><w:r><w:t>Line one</w:t></w:r></w:p></w:document>",
|
||||
)
|
||||
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")
|
||||
151
tests/test_prepare_export.py
Normal file
151
tests/test_prepare_export.py
Normal file
@ -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
|
||||
50
tests/test_preset_skips.py
Normal file
50
tests/test_preset_skips.py
Normal file
@ -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"
|
||||
112
tests/test_server.py
Normal file
112
tests/test_server.py
Normal file
@ -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
|
||||
162
tests/test_server_extended.py
Normal file
162
tests/test_server_extended.py
Normal file
@ -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")
|
||||
58
tests/test_slack_export.py
Normal file
58
tests/test_slack_export.py
Normal file
@ -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
|
||||
203
tests/test_slack_export_extended.py
Normal file
203
tests/test_slack_export_extended.py
Normal file
@ -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")
|
||||
Loading…
x
Reference in New Issue
Block a user