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
34 changes: 34 additions & 0 deletions app/lib/src/export/program_share_bundle.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:compendium_core/compendium_core.dart';

import 'share_sanitization.dart';

/// Builds a self-contained "share this program + its dances" bundle (ROADMAP
/// §4.3, issue #298 — AirDrop/OS share-sheet sharing, send side).
///
Expand All @@ -20,11 +22,30 @@ import 'package:compendium_core/compendium_core.dart';
/// program itself (its `slots` carry their own `text`), so nothing extra is
/// needed for them here.
///
/// [choreographerFor] resolves an author id (from a bundled dance's `authorIds`)
/// to its full [Choreographer], so author attribution survives the round-trip:
/// the receive-side importer reads incoming author *names* from the bundle's own
/// `CompendiumArchive.choreographers` (a receiver cannot resolve the sender's
/// author ids). Only the choreographers actually referenced by the bundled
/// dances are included (deduped by id) — the bundle stays minimal and never
/// leaks unrelated authors. (The emitted archive is canonicalized by
/// [encodeArchive], which sorts entities by id, so the serialized order is by
/// id rather than reference order — only the set of included choreographers is
/// significant.) An id that can't be resolved is skipped (best-effort, never
/// fatal — mirrors [danceFor]).
///
/// Privacy (issue #412, and the [Choreographer] model's contract): a
/// choreographer's `email`/`location` are private contact data that MUST NOT
/// leave the device in a shareable export. Each included choreographer is
/// therefore sanitized here — `email`/`location` are cleared — before it is
/// embedded, so sharing carries only public attribution (name/website/notes).
///
/// [now] stamps the archive's `exportedAt`; it defaults to the current time and
/// is injectable for deterministic tests.
String buildProgramShareBundle(
Program program, {
required Dance? Function(String danceId) danceFor,
required Choreographer? Function(String id) choreographerFor,
DateTime? now,
}) {
final dances = <Dance>[];
Expand All @@ -36,11 +57,24 @@ String buildProgramShareBundle(
if (dance != null) dances.add(dance);
}

final choreographers = <Choreographer>[];
final seenAuthors = <String>{};
for (final dance in dances) {
for (final authorId in dance.authorIds) {
if (!seenAuthors.add(authorId)) continue;
final choreographer = choreographerFor(authorId);
if (choreographer == null) continue;
// Strip private contact fields before the record leaves the device.
choreographers.add(sanitizeChoreographerForShare(choreographer));
}
}

return encodeArchive(
CompendiumArchive(
exportedAt: (now ?? DateTime.now()).toUtc(),
programs: [program],
dances: dances,
choreographers: choreographers,
),
);
}
Expand Down
18 changes: 18 additions & 0 deletions app/lib/src/export/share_sanitization.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'package:compendium_core/compendium_core.dart';

/// Redacts a [Choreographer]'s private contact data for inclusion in a
/// **shareable** export (any program/dance share path).
///
/// The [Choreographer] model documents (choreographer.dart:6-9) that [email]
/// and [location] are private contact data that are safe in the user's own
/// full-DB snapshot/backup but MUST NOT be emitted in any shareable export.
/// This helper enforces that at the single send-side choke point: it clears
/// `email` and `location` while preserving the public attribution fields
/// (`name`, `website`, `notes`, `deceased`, and identity/timestamps).
///
/// It is intentionally a top-level, reusable function (not private to any one
/// builder): the program-share bundle uses it today, and the forthcoming
/// dance-share path (issue #298) must apply the identical redaction rather than
/// re-deriving an inline copyWith that could silently drift.
Choreographer sanitizeChoreographerForShare(Choreographer choreographer) =>
choreographer.copyWith(clearEmail: true, clearLocation: true);
1 change: 1 addition & 0 deletions app/lib/src/screens/program_editor_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ class _ProgramEditorScreenState extends State<ProgramEditorScreen>
program: _draftProgram!,
titleFor: _titleForDance,
danceFor: (id) => _data?.dancesById[id],
choreographerFor: (id) => _data?.choreographersById[id],
),
if (!widget.isNew && _existing != null) ...[
if (_slots.any((s) => s.danceId != null))
Expand Down
1 change: 1 addition & 0 deletions app/lib/src/screens/program_summary_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ class _ProgramSummaryPaneState extends State<ProgramSummaryPane> {
program: program,
titleFor: (id) => _danceTitles[id],
danceFor: (id) => _dances[id],
choreographerFor: (id) => _collectionData?.choreographersById[id],
),
if (program.slots.any((s) => s.danceId != null))
IconButton(
Expand Down
7 changes: 7 additions & 0 deletions app/lib/src/search/collection_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import '../models/dance_list_entry.dart';
class CollectionData {
CollectionData({
required this.dancesById,
required this.choreographersById,
required this.choreographerNames,
required this.tagNames,
required this.customFieldDefs,
Expand All @@ -38,6 +39,10 @@ class CollectionData {
});

final Map<String, Dance> dancesById;

/// All choreographers keyed by id, so a share/export path can resolve a
/// dance's `authorIds` to full [Choreographer] records (mirrors [dancesById]).
final Map<String, Choreographer> choreographersById;
final Map<String, String> choreographerNames;
final Map<String, String> tagNames;
final List<CustomFieldDef> customFieldDefs;
Expand Down Expand Up @@ -90,6 +95,7 @@ class CollectionData {
final callCounts = await repos.programs.countByDance();

final dancesById = {for (final d in dances) d.id: d};
final choreographersById = {for (final c in choreographers) c.id: c};
final choreographerNames = {for (final c in choreographers) c.id: c.name};
final tagNames = {for (final t in tags) t.id: t.name};

Expand Down Expand Up @@ -133,6 +139,7 @@ class CollectionData {

return CollectionData(
dancesById: dancesById,
choreographersById: choreographersById,
choreographerNames: choreographerNames,
tagNames: tagNames,
customFieldDefs: defs,
Expand Down
13 changes: 12 additions & 1 deletion app/lib/src/widgets/program_export_menu.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class ProgramExportMenu extends StatelessWidget {
required this.program,
required this.titleFor,
this.danceFor,
this.choreographerFor,
this.shareInvoker,
this.bundleFileWriter,
this.pdfLayouter,
Expand All @@ -96,6 +97,12 @@ class ProgramExportMenu extends StatelessWidget {
/// bundle. When `null`, that action is omitted (there is nothing to embed).
final Dance? Function(String danceId)? danceFor;

/// Resolves a dance's author id to its full [Choreographer], so the "Share
/// (program + dances)" action can embed the choreographers its bundled dances
/// reference and author attribution survives the round-trip. Optional and
/// best-effort: an unresolved id is simply omitted from the bundle.
final Choreographer? Function(String id)? choreographerFor;

/// Test seam for the share call; defaults to [SharePlus.instance.share].
final ShareInvoker? shareInvoker;

Expand Down Expand Up @@ -142,7 +149,11 @@ class ProgramExportMenu extends StatelessWidget {
final resolveDance = danceFor;
if (resolveDance == null) return;

final json = buildProgramShareBundle(program, danceFor: resolveDance);
final json = buildProgramShareBundle(
program,
danceFor: resolveDance,
choreographerFor: choreographerFor ?? (_) => null,
);
final fileName = programShareBundleFileName(program.title);

final writeFile = bundleFileWriter ?? writeBundleTempFile;
Expand Down
Loading