diff --git a/CHANGELOG.md b/CHANGELOG.md index a1befcf..6c49207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v0.15.0 — feat: sync pushes user attachments verbatim + +### Added + +* **`millet/sync.py`** — files a user drops into `/attachments/` are + now pushed into an `attachments/` subdirectory of the meeting folder, with + their **names intact**. `_collect_files()` gained `_collect_attachments()`, + which deliberately bypasses both `PUSH_SUFFIXES` and the descriptive-rename + map: the map keys off the suffix alone, so an attached `slides.pdf` would + land as `transcript.pdf` (or force the real transcript to keep its raw + name), and the allowlist dropped every image, office document and video — + most of what people attach to a meeting. The copy loop in `sync_session()` + now creates `dest.parent` so the subdirectory prefix works; `git add` + needed no change. + + Guards: symlinks are skipped (the collected pairs are copied into a git + clone, and a link could point anywhere on the host), as are dotfiles and + nested directories. `MAX_ATTACHMENTS` (50) and `MAX_ATTACHMENTS_BYTES` + (100 MB) cap what a mis-aimed folder can push into the archive repo. + Sessions without an `attachments/` directory collect exactly what they did + before. + + This is the upstream half of vezir's attachment-folder workflow: vezir + stores attachments in the session directory it hands to `millet sync`, so + nothing further is needed on that side to get them into the team repo. + ## v0.14.1 — fix: speaker relabel swap collapsed both speakers to one name ### Fixed — correctness diff --git a/millet/__init__.py b/millet/__init__.py index 7e8c0c6..79d13ee 100644 --- a/millet/__init__.py +++ b/millet/__init__.py @@ -51,4 +51,4 @@ # may still be the one installed. Honor it as a fallback. __version__ = _pkg_version("meetscribe-offline") except Exception: - __version__ = "0.14.1" + __version__ = "0.15.0" diff --git a/millet/sync.py b/millet/sync.py index f1624b1..d0bd8cf 100644 --- a/millet/sync.py +++ b/millet/sync.py @@ -154,6 +154,12 @@ def _resolve_sync_config_path( # ─── Files to push (by suffix) ─────────────────────────────────────────────── PUSH_SUFFIXES = {".md", ".txt", ".pdf", ".srt", ".json"} +# User-supplied meeting attachments live in this subdirectory of the session +# dir and are pushed verbatim into the same subdirectory of the meeting folder. +# See _collect_attachments. +ATTACHMENTS_SUBDIR = "attachments" +MAX_ATTACHMENTS = 50 +MAX_ATTACHMENTS_BYTES = 100 * 1024 * 1024 # Exclude session metadata and large raw files EXCLUDE_PATTERNS = { ".session.json", @@ -696,6 +702,49 @@ def _collect_files(session_dir: Path) -> list[tuple[Path, str]]: used_names.add(dest_name) result.append((f, dest_name)) + + result.extend(_collect_attachments(session_dir)) + return result + + +def _collect_attachments(session_dir: Path) -> list[tuple[Path, str]]: + """Return (source, ``attachments/``) pairs for user-supplied files. + + Files a user dropped into ``/attachments/`` — slides, agendas, + screenshots — are pushed verbatim: neither ``PUSH_SUFFIXES`` nor the + descriptive-rename map above applies to them. Both would be wrong here. + The rename map keys off the suffix alone, so an attached ``slides.pdf`` + would land as ``transcript.pdf``, and the suffix allowlist would drop + every image, office document and video. + + Symlinks are skipped: the pairs returned here are copied into a git + clone, and a link could point anywhere on the host. Count and total + size are capped so a mis-aimed folder can't bloat the archive repo. + """ + adir = session_dir / ATTACHMENTS_SUBDIR + if not adir.is_dir(): + return [] + + result: list[tuple[Path, str]] = [] + total = 0 + for f in sorted(adir.iterdir()): + if f.is_symlink() or not f.is_file() or f.name.startswith("."): + continue + if len(result) >= MAX_ATTACHMENTS: + log.warning( + "sync: more than %d attachments in %s — pushing the first %d only", + MAX_ATTACHMENTS, adir, MAX_ATTACHMENTS, + ) + break + size = f.stat().st_size + if total + size > MAX_ATTACHMENTS_BYTES: + log.warning( + "sync: attachments in %s exceed %d bytes — skipping %s and the rest", + adir, MAX_ATTACHMENTS_BYTES, f.name, + ) + break + total += size + result.append((f, f"{ATTACHMENTS_SUBDIR}/{f.name}")) return result @@ -819,6 +868,8 @@ def _log(msg: str) -> None: copied: list[Path] = [] for src, dest_name in source_files: dest = target_dir / dest_name + # dest_name carries an "attachments/" prefix for attached files. + dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dest) copied.append(dest) _log(f" Staged: {dest.relative_to(repo)}") diff --git a/pyproject.toml b/pyproject.toml index 265bdc1..79d89ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "millet-pipeline" -version = "0.14.1" +version = "0.15.0" description = "Meeting transcription pipeline (diarization, AI summaries, PDF output) under the vezir ecosystem. Builds on millet-record for capture. Named after the Ottoman millet system. Successor to meetscribe-offline." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_sync_core.py b/tests/test_sync_core.py index 7bd0918..2e1e84a 100644 --- a/tests/test_sync_core.py +++ b/tests/test_sync_core.py @@ -356,6 +356,108 @@ def test_sync_session_disambiguates_different_session( assert suffixed, "second session must land in a disambiguated folder" +# ─── attachments passthrough ───────────────────────────────────────────────── + + +def _attach(sdir: Path, name: str, content: bytes = b"data") -> Path: + adir = sdir / sync.ATTACHMENTS_SUBDIR + adir.mkdir(exist_ok=True) + p = adir / name + p.write_bytes(content) + return p + + +def test_collect_attachments_bypasses_allowlist_and_rename_map(tmp_path): + """Attachment names are the user's and must survive verbatim — the + suffix-keyed rename map would turn slides.pdf into transcript.pdf and + PUSH_SUFFIXES would drop images and office documents entirely.""" + sdir = _make_session(tmp_path, "meeting-20260706-100000", "01ATTACH") + _attach(sdir, "slides.pdf") + _attach(sdir, "diagram.png") + _attach(sdir, "agenda.pptx") + + dests = dict((d, s) for s, d in sync._collect_files(sdir)) + # The session's own transcript still gets its descriptive name. + assert "transcript.txt" in dests + # Attachments keep theirs, under the subdir, whatever the suffix. + assert "attachments/slides.pdf" in dests + assert "attachments/diagram.png" in dests + assert "attachments/agenda.pptx" in dests + # Nothing was renamed into the pipeline's namespace. + assert "transcript.pdf" not in dests + + +def test_collect_attachments_skips_symlinks_dotfiles_and_subdirs(tmp_path): + """These pairs get copied into a git clone: a symlink could point + anywhere on the host.""" + sdir = _make_session(tmp_path, "meeting-20260706-100000", "01ATTACH") + _attach(sdir, "keep.pdf") + _attach(sdir, ".hidden") + outside = tmp_path / "secret.txt" + outside.write_text("ssh key") + (sdir / sync.ATTACHMENTS_SUBDIR / "link.txt").symlink_to(outside) + (sdir / sync.ATTACHMENTS_SUBDIR / "nested").mkdir() + + dests = [d for _, d in sync._collect_files(sdir)] + assert "attachments/keep.pdf" in dests + assert not any( + d.endswith(("link.txt", ".hidden", "nested")) for d in dests + ), dests + + +def test_collect_files_unchanged_without_attachments_dir(tmp_path): + sdir = _make_session(tmp_path, "meeting-20260706-100000", "01ATTACH") + dests = [d for _, d in sync._collect_files(sdir)] + assert dests == ["summary.md", "transcript.txt"] + + +def test_collect_attachments_caps_count(monkeypatch, tmp_path): + monkeypatch.setattr(sync, "MAX_ATTACHMENTS", 2) + sdir = _make_session(tmp_path, "meeting-20260706-100000", "01ATTACH") + for i in range(5): + _attach(sdir, f"f{i}.png") + + attached = [d for _, d in sync._collect_files(sdir) if d.startswith("attachments/")] + assert attached == ["attachments/f0.png", "attachments/f1.png"] + + +def test_collect_attachments_caps_total_bytes(monkeypatch, tmp_path): + monkeypatch.setattr(sync, "MAX_ATTACHMENTS_BYTES", 20) + sdir = _make_session(tmp_path, "meeting-20260706-100000", "01ATTACH") + _attach(sdir, "a.png", b"x" * 15) + _attach(sdir, "b.png", b"x" * 15) + + attached = [d for _, d in sync._collect_files(sdir) if d.startswith("attachments/")] + assert attached == ["attachments/a.png"] + + +def test_sync_session_pushes_attachments_subdir(monkeypatch, tmp_path, local_remote): + monkeypatch.setattr(sync, "CLONE_BASE_DIR", tmp_path / "clones") + monkeypatch.setattr( + sync, "load_sync_config", + lambda team=None, config_path=None: { + "repo_url": str(local_remote), "meetings": [], + }, + ) + + sdir = _make_session(tmp_path, "meeting-20260706-100000", "01TESTULID") + _attach(sdir, "slides.pdf", b"%PDF-1.4 slides") + _attach(sdir, "photo of board.png", b"\x89PNG board") + match = sync.MeetingMatch(name="Weekly", folder="weekly") + sync.sync_session(sdir, match, progress_callback=lambda m: None) + + check = tmp_path / "check" + _git("clone", str(local_remote), str(check)) + meeting_dir = check / "meetings" / "2026-07-06_weekly" + assert (meeting_dir / "attachments" / "slides.pdf").read_bytes() == b"%PDF-1.4 slides" + assert ( + meeting_dir / "attachments" / "photo of board.png" + ).read_bytes() == b"\x89PNG board" + # The pipeline's own artifacts are untouched by the attachment pass. + assert (meeting_dir / "transcript.txt").read_text() == "transcript\n" + assert not (meeting_dir / "transcript.pdf").exists() + + # ─── sanity: dates used above ────────────────────────────────────────────────