fix(feedback): a suggestion takes along only its own tree's sealed files (ADR 0019)
The card's, note's or step's own id goes with a suggestion, and a sealed file goes along readable only where it is under the key of that item's tree: a file of another private tree, named by a row the instance planted, is left out and never uploaded readable (the short reading of 22 Oct, finding 12). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
ba7067a861
commit
5a61f2ea07
7 changed files with 148 additions and 38 deletions
|
|
@ -229,6 +229,11 @@ class AssetRepository(
|
||||||
@override
|
@override
|
||||||
Future<bool> exists(String id) async => await _rows.byId(id) != null;
|
Future<bool> exists(String id) async => await _rows.byId(id) != null;
|
||||||
|
|
||||||
|
/// The key of the tree the item [id] of [kind] is in; null while
|
||||||
|
/// readable.
|
||||||
|
Future<String?> keyOfItem(ItemKind kind, String id) =>
|
||||||
|
_sealing.keyOfItem(kind, id);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf(
|
Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf(
|
||||||
String id,
|
String id,
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
||||||
feedback.suggestCard(
|
feedback.suggestCard(
|
||||||
selected.content.normalized,
|
selected.content.normalized,
|
||||||
body: body,
|
body: body,
|
||||||
|
itemId: selected.cardId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|
|
||||||
|
|
@ -68,48 +68,72 @@ class FeedbackRepository(
|
||||||
/// Suggests a copy's card, as the person has it ([content]), to the
|
/// Suggests a copy's card, as the person has it ([content]), to the
|
||||||
/// author of the published studyset it came from, with [body] saying
|
/// author of the published studyset it came from, with [body] saying
|
||||||
/// why. Throws an [ArgumentError] for a card of the person's own.
|
/// why. Throws an [ArgumentError] for a card of the person's own.
|
||||||
Future<void> suggestCard(CardContent content, {required String body}) =>
|
///
|
||||||
_suggest(
|
/// [itemId], the card's own id here, lets its files go along, those of
|
||||||
content.published,
|
/// its own tree alone (the short reading of 22 Oct).
|
||||||
FeedbackTargetKind.card,
|
Future<void> suggestCard(
|
||||||
_json(
|
CardContent content, {
|
||||||
encodeCardPayload(
|
required String body,
|
||||||
content
|
String? itemId,
|
||||||
.withField(CardField.published, null)
|
}) => _suggest(
|
||||||
.withField(CardField.report, null),
|
content.published,
|
||||||
),
|
FeedbackTargetKind.card,
|
||||||
),
|
_json(
|
||||||
body,
|
encodeCardPayload(
|
||||||
);
|
content
|
||||||
|
.withField(CardField.published, null)
|
||||||
|
.withField(CardField.report, null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body,
|
||||||
|
itemId: itemId,
|
||||||
|
);
|
||||||
|
|
||||||
/// Suggests a copy's note, as with [suggestCard].
|
/// Suggests a copy's note, as with [suggestCard].
|
||||||
Future<void> suggestNote(NoteContent content, {required String body}) =>
|
Future<void> suggestNote(
|
||||||
_suggest(
|
NoteContent content, {
|
||||||
content.published,
|
required String body,
|
||||||
FeedbackTargetKind.note,
|
String? itemId,
|
||||||
_json(
|
}) => _suggest(
|
||||||
encodeNotePayload(content.withField(NoteField.published, null)),
|
content.published,
|
||||||
),
|
FeedbackTargetKind.note,
|
||||||
body,
|
_json(
|
||||||
);
|
encodeNotePayload(content.withField(NoteField.published, null)),
|
||||||
|
),
|
||||||
|
body,
|
||||||
|
itemId: itemId,
|
||||||
|
);
|
||||||
|
|
||||||
/// Suggests a copy's step, as with [suggestCard].
|
/// Suggests a copy's step, as with [suggestCard].
|
||||||
Future<void> suggestStep(StepContent content, {required String body}) =>
|
Future<void> suggestStep(
|
||||||
_suggest(
|
StepContent content, {
|
||||||
content.published,
|
required String body,
|
||||||
FeedbackTargetKind.step,
|
String? itemId,
|
||||||
_json(
|
}) => _suggest(
|
||||||
encodeStepPayload(content.withField(StepField.published, null)),
|
content.published,
|
||||||
),
|
FeedbackTargetKind.step,
|
||||||
body,
|
_json(
|
||||||
);
|
encodeStepPayload(content.withField(StepField.published, null)),
|
||||||
|
),
|
||||||
|
body,
|
||||||
|
itemId: itemId,
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> _suggest(
|
Future<void> _suggest(
|
||||||
PublishedRef? origin,
|
PublishedRef? origin,
|
||||||
FeedbackTargetKind kind,
|
FeedbackTargetKind kind,
|
||||||
Map<String, Object?> proposed,
|
Map<String, Object?> proposed,
|
||||||
String body,
|
String body, {
|
||||||
) async {
|
String? itemId,
|
||||||
|
}) async {
|
||||||
|
// The key of the item's own tree: only files under it go along readable.
|
||||||
|
final itemKey = itemId == null
|
||||||
|
? null
|
||||||
|
: await _assets.keyOfItem(switch (kind) {
|
||||||
|
FeedbackTargetKind.card => ItemKind.card,
|
||||||
|
FeedbackTargetKind.note => ItemKind.note,
|
||||||
|
_ => ItemKind.step,
|
||||||
|
}, itemId);
|
||||||
if (origin == null) {
|
if (origin == null) {
|
||||||
throw ArgumentError.value(origin, 'origin', 'is not a copy');
|
throw ArgumentError.value(origin, 'origin', 'is not a copy');
|
||||||
}
|
}
|
||||||
|
|
@ -117,11 +141,20 @@ class FeedbackRepository(
|
||||||
final files = <FeedbackFile>[];
|
final files = <FeedbackFile>[];
|
||||||
for (final assetId in assetIdsIn(proposed)) {
|
for (final assetId in assetIdsIn(proposed)) {
|
||||||
if (await _assets.fileOf(assetId) case final file?) {
|
if (await _assets.fileOf(assetId) case final file?) {
|
||||||
// A sealed file is left out: the instance could have named another
|
// A sealed file goes along readable only where it is of the item's
|
||||||
// private tree's file through a row it planted, and the suggestion
|
// own tree: the instance could have named another private tree's
|
||||||
// would upload it readable (the short reading of 22 Oct).
|
// file through a row it planted (the short reading of 22 Oct).
|
||||||
if (await _assets.keyIdOf(assetId) != null) {
|
final fileKey = await _assets.keyIdOf(assetId);
|
||||||
continue;
|
if (fileKey != null) {
|
||||||
|
if (fileKey != itemKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await _assets.add(
|
||||||
|
file.bytes,
|
||||||
|
fileName: file.fileName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
studysetId: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
files.add(
|
files.add(
|
||||||
FeedbackFile(
|
FeedbackFile(
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,9 @@ class _StepEditorScreenState() extends State<StepEditorScreen> {
|
||||||
send: (feedback, body) => feedback.suggestStep(
|
send: (feedback, body) => feedback.suggestStep(
|
||||||
state.content.normalized,
|
state.content.normalized,
|
||||||
body: body,
|
body: body,
|
||||||
|
itemId: GoRouterState.of(
|
||||||
|
context,
|
||||||
|
).pathParameters['stepId'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
|
||||||
send: (feedback, body) => feedback.suggestNote(
|
send: (feedback, body) => feedback.suggestNote(
|
||||||
state.content.normalized,
|
state.content.normalized,
|
||||||
body: body,
|
body: body,
|
||||||
|
itemId: state.noteId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,7 @@ class _CardsPanelState()
|
||||||
send: (feedback, body) => feedback.suggestCard(
|
send: (feedback, body) => feedback.suggestCard(
|
||||||
draft.content.normalized,
|
draft.content.normalized,
|
||||||
body: body,
|
body: body,
|
||||||
|
itemId: draft.cardId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,72 @@ void main() {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a suggestion takes along readable only files of the item's own "
|
||||||
|
'tree: one of another private tree, named by a row the instance '
|
||||||
|
'planted, is never uploaded', () async {
|
||||||
|
final rows = FakeAssetGateway();
|
||||||
|
final cache = MemoryAssetFileCache();
|
||||||
|
final sealedAssets = AssetRepository(
|
||||||
|
rows,
|
||||||
|
FakeAssetServerGateway(),
|
||||||
|
cache,
|
||||||
|
FakeAssetSealing(
|
||||||
|
keys: {'own-tree': 'k-own', 'other-tree': 'k-other'},
|
||||||
|
itemKeys: {'card-1': 'k-own'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final sealedFeedback = FeedbackRepository(
|
||||||
|
written,
|
||||||
|
server,
|
||||||
|
cards,
|
||||||
|
notes,
|
||||||
|
LearningPathRepository(FakeLearningPathGateway()),
|
||||||
|
sealedAssets,
|
||||||
|
);
|
||||||
|
final mine = await sealedAssets.add(
|
||||||
|
image,
|
||||||
|
fileName: 'cat.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
studysetId: 'own-tree',
|
||||||
|
);
|
||||||
|
final secret = utf8.encode('a secret picture');
|
||||||
|
final theirs = await sealedAssets.add(
|
||||||
|
secret,
|
||||||
|
fileName: 'secret.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
studysetId: 'other-tree',
|
||||||
|
);
|
||||||
|
Map<String, Object?> imageBlock(String id) => {
|
||||||
|
'type': 'image',
|
||||||
|
'data': {'url': 'asset:$id'},
|
||||||
|
};
|
||||||
|
|
||||||
|
await sealedFeedback.suggestCard(
|
||||||
|
CardContent(
|
||||||
|
front: RichContent({
|
||||||
|
'document': {
|
||||||
|
'type': 'page',
|
||||||
|
'children': [imageBlock(mine), imageBlock(theirs)],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
back: RichContent.plain('a cat'),
|
||||||
|
published: origin,
|
||||||
|
),
|
||||||
|
body: 'Pictures help.',
|
||||||
|
itemId: 'card-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
final files = written.threads.single.json['files']! as List<Object?>;
|
||||||
|
expect([for (final file in files) (file! as Map)['assetId']], [mine]);
|
||||||
|
// A readable copy of the own file waits to upload; the other's never.
|
||||||
|
final readable = {
|
||||||
|
for (final row in rows.rows)
|
||||||
|
if (row.keyId == null) row.sha256,
|
||||||
|
};
|
||||||
|
expect(readable, {imageHash});
|
||||||
|
expect(readable, isNot(contains(sha256Of(secret))));
|
||||||
|
});
|
||||||
|
|
||||||
test("an accepted suggestion becomes the author's own card, its files "
|
test("an accepted suggestion becomes the author's own card, its files "
|
||||||
'theirs, and is recorded so', () async {
|
'theirs, and is recorded so', () async {
|
||||||
final id = await cards.create(
|
final id = await cards.create(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue