Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
# Changelog

## v0.15.0 — feat: sync pushes user attachments verbatim

### Added

* **`millet/sync.py`** — files a user drops into `<session>/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
Expand Down
2 changes: 1 addition & 1 deletion millet/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
51 changes: 51 additions & 0 deletions millet/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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/<name>``) pairs for user-supplied files.

Files a user dropped into ``<session>/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


Expand Down Expand Up @@ -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)}")
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
102 changes: 102 additions & 0 deletions tests/test_sync_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────


Expand Down
Loading