Limit how much room people's files take
An anonymous person may keep 100 MB of images and sounds, an identified one 500 MB, and one person more by a file_allowance row the operator adds; all of them together 15 GB, since every file is in the database and in each dump the server keeps (decided 2026-10-02). Each comes from the environment (FILE_LIMIT_*_MB), with these defaults, documented in deploy/.env.example. The server checks when an upload starts, counting unfinished uploads so they can't fill the disk unnoticed, and again when it finishes, counting what is kept, so uploads started side by side can't overshoot; a refusal is a FileLimitExceededException, and an upload refused as it finishes is deleted. deletePerson deletes the allowance with the person. Migration: the new table file_allowance, only added. The app keeps a refused file waiting on the device and stops that round of uploads; the Anki import warns when a deck's files may not fit in the room left (asset.usage()). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
53b5d56e79
commit
ca69f74fd9
41 changed files with 9884 additions and 20 deletions
|
|
@ -115,7 +115,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
## CI and deploys
|
## CI and deploys
|
||||||
|
|
||||||
- **CI** (`.forgejo/workflows/ci.yaml`) runs `tool/check.sh` on every push, on the Forgejo runner on sahaquiel (`deploy/runner/`). Jobs run directly on the host as the unprivileged user `runner`, never in containers: Docker access would be root on the production server. The runner's systemd unit keeps jobs away from production (no `/home`, no Docker socket, Docker's container networks unreachable, memory and CPU capped); keep it that way when changing it. `runner` has user ID 61000 and must never share one with a container's processes (PostgreSQL and the server run as 999, not remapped): the same user ID can read their environment and files. The job's first step (*Sandbox*) fails if any of this leaks. Flutter is pinned in the workflow (`FLUTTER_VERSION`): move it together with the local one. The server tests there get throwaway passwords from the environment (`SERVERPOD_…`, new for every job), since `passwords.yaml` is never committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI also runs `serverpod generate` and fails if the committed generated code differs from its output. CI runs on pushes only: a pull request from a fork would run its code on the production server.
|
- **CI** (`.forgejo/workflows/ci.yaml`) runs `tool/check.sh` on every push, on the Forgejo runner on sahaquiel (`deploy/runner/`). Jobs run directly on the host as the unprivileged user `runner`, never in containers: Docker access would be root on the production server. The runner's systemd unit keeps jobs away from production (no `/home`, no Docker socket, Docker's container networks unreachable, memory and CPU capped); keep it that way when changing it. `runner` has user ID 61000 and must never share one with a container's processes (PostgreSQL and the server run as 999, not remapped): the same user ID can read their environment and files. The job's first step (*Sandbox*) fails if any of this leaks. Flutter is pinned in the workflow (`FLUTTER_VERSION`): move it together with the local one. The server tests there get throwaway passwords from the environment (`SERVERPOD_…`, new for every job), since `passwords.yaml` is never committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI also runs `serverpod generate` and fails if the committed generated code differs from its output. CI runs on pushes only: a pull request from a fork would run its code on the production server.
|
||||||
- **A green `main` deploys learn.halkyon.eu** through `deploy/redeploy.sh`, the only command the runner's key may run as `deploy` (pinned in `authorized_keys`, from sahaquiel itself only). It deploys the commit CI checked while it is still the tip, as a fast-forward, only if every new commit is signed by a key in `deploy/allowed-signers` (the deployed copy decides), after dumping the database (the last 10 dumps stay in `~deploy/backups`), applies the migrations in a run of their own before switching (`--role=maintenance --apply-migrations`: a failing migration stops the deploy while the old server keeps serving, so migrations must keep the old server working until the switch), and fails unless the new server answers. A failed deploy leaves the checkout at the deployed commit; run the workflow again to retry. By hand: `ssh sahaquiel-deploy halkyon-learn/deploy/redeploy.sh <commit>`. Never `docker compose down -v` there.
|
- **A green `main` deploys learn.halkyon.eu** through `deploy/redeploy.sh`, the only command the runner's key may run as `deploy` (pinned in `authorized_keys`, from sahaquiel itself only). It deploys the commit CI checked while it is still the tip, as a fast-forward, only if every new commit is signed by a key in `deploy/allowed-signers` (the deployed copy decides), after dumping the database (the last 3 dumps stay in `~deploy/backups`: the way back from a deploy, not backups), applies the migrations in a run of their own before switching (`--role=maintenance --apply-migrations`: a failing migration stops the deploy while the old server keeps serving, so migrations must keep the old server working until the switch), and fails unless the new server answers. A failed deploy leaves the checkout at the deployed commit; run the workflow again to retry. By hand: `ssh sahaquiel-deploy halkyon-learn/deploy/redeploy.sh <commit>`. Never `docker compose down -v` there.
|
||||||
|
|
||||||
## Rules the code relies on
|
## Rules the code relies on
|
||||||
|
|
||||||
|
|
@ -135,7 +135,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`). A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, 20 per studyset and local day. The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews.
|
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`). A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, 20 per studyset and local day. The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews.
|
||||||
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
||||||
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
||||||
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB; `deletePerson` deletes the person's, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
||||||
- **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3, e.g. `anki`); the dashboard doesn't count them as written. The Anki import (`feature/anki/`) reads `.apkg`/`.colpkg` packages of every format on an isolate of its own (`AnkiPackageReader`), zstd through our fork of `pure_zstd` (Anki's frames don't record their size), and its fixtures come from Anki itself (`tool/anki/make_fixtures.py`).
|
- **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3, e.g. `anki`); the dashboard doesn't count them as written. The Anki import (`feature/anki/`) reads `.apkg`/`.colpkg` packages of every format on an isolate of its own (`AnkiPackageReader`), zstd through our fork of `pure_zstd` (Anki's frames don't record their size), and its fixtures come from Anki itself (`tool/anki/make_fixtures.py`).
|
||||||
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
|
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
|
||||||
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,16 @@ GITHUB_CLIENT_SECRET=
|
||||||
# use the instance. Empty: the project's repository, which is right for unmodified code.
|
# use the instance. Empty: the project's repository, which is right for unmodified code.
|
||||||
# Running modified code, set it to where your modified source is published.
|
# Running modified code, set it to where your modified source is published.
|
||||||
SOURCE_URL=
|
SOURCE_URL=
|
||||||
|
|
||||||
|
# How much room people's images and sounds may take on this instance, in MiB. Empty: the
|
||||||
|
# defaults, 100 for an anonymous person (anonymous accounts are meant to become identified
|
||||||
|
# ones), 500 for one signed in with GitHub, and 15000 for everyone together (0: no limit
|
||||||
|
# for everyone together). Every file is in the database, so also in each of the 3 dumps
|
||||||
|
# this server keeps (and a fourth while a deploy writes its own): files take five times
|
||||||
|
# their size on the disk. One person may get more than the default; as the operator,
|
||||||
|
# in deploy/ (their id is their account's, "authUserId"):
|
||||||
|
# docker compose exec postgres psql -U halkyon_learn -d halkyon_learn -c "INSERT INTO
|
||||||
|
# file_allowance (\"authUserId\", \"maxBytes\", note) VALUES ('<id>', 2147483648, 'asked by mail')"
|
||||||
|
FILE_LIMIT_ANONYMOUS_MB=
|
||||||
|
FILE_LIMIT_IDENTIFIED_MB=
|
||||||
|
FILE_LIMIT_INSTANCE_MB=
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ services:
|
||||||
SERVERPOD_PASSWORD_githubClientSecret: ${GITHUB_CLIENT_SECRET:-}
|
SERVERPOD_PASSWORD_githubClientSecret: ${GITHUB_CLIENT_SECRET:-}
|
||||||
# Where the source of what this instance runs is; empty: the project's.
|
# Where the source of what this instance runs is; empty: the project's.
|
||||||
SOURCE_URL: ${SOURCE_URL:-}
|
SOURCE_URL: ${SOURCE_URL:-}
|
||||||
|
# How much room people's files may take, in MiB; empty: the defaults.
|
||||||
|
FILE_LIMIT_ANONYMOUS_MB: ${FILE_LIMIT_ANONYMOUS_MB:-}
|
||||||
|
FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-}
|
||||||
|
FILE_LIMIT_INSTANCE_MB: ${FILE_LIMIT_INSTANCE_MB:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
128
halkyon_learn_client/lib/src/protocol/asset/file_allowance.dart
Normal file
128
halkyon_learn_client/lib/src/protocol/asset/file_allowance.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||||
|
/* To generate run: "serverpod generate" */
|
||||||
|
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// ignore_for_file: non_constant_identifier_names
|
||||||
|
// ignore_for_file: public_member_api_docs
|
||||||
|
// ignore_for_file: type_literal_in_constant_pattern
|
||||||
|
// ignore_for_file: use_super_parameters
|
||||||
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
|
|
||||||
|
/// More room for one person's files than the instance's default (decided
|
||||||
|
/// 2026-10-02): the operator grants it on request, by adding a row. Only the
|
||||||
|
/// server reads it; deleting the person deletes it.
|
||||||
|
abstract class FileAllowance
|
||||||
|
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||||
|
FileAllowance._({
|
||||||
|
this.id,
|
||||||
|
required this.authUserId,
|
||||||
|
required this.maxBytes,
|
||||||
|
this.note,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FileAllowance({
|
||||||
|
int? id,
|
||||||
|
required _isc.UuidValue authUserId,
|
||||||
|
required int maxBytes,
|
||||||
|
String? note,
|
||||||
|
}) = _FileAllowanceImpl;
|
||||||
|
|
||||||
|
factory FileAllowance.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
return FileAllowance(
|
||||||
|
id: jsonSerialization['id'] as int?,
|
||||||
|
authUserId: _isc.UuidValueJsonExtension.fromJson(
|
||||||
|
jsonSerialization['authUserId'],
|
||||||
|
),
|
||||||
|
maxBytes: jsonSerialization['maxBytes'] as int,
|
||||||
|
note: jsonSerialization['note'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The database id, set if the object has been inserted into the
|
||||||
|
/// database or if it has been fetched from the database. Otherwise,
|
||||||
|
/// the id will be null.
|
||||||
|
int? id;
|
||||||
|
|
||||||
|
/// The person: the AuthUser's id.
|
||||||
|
_isc.UuidValue authUserId;
|
||||||
|
|
||||||
|
/// How many bytes of files they may keep.
|
||||||
|
int maxBytes;
|
||||||
|
|
||||||
|
/// Why, for the operator (e.g. "asked by mail, 2026-10-02").
|
||||||
|
String? note;
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [FileAllowance]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
FileAllowance copyWith({
|
||||||
|
int? id,
|
||||||
|
_isc.UuidValue? authUserId,
|
||||||
|
int? maxBytes,
|
||||||
|
String? note,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'__className__': 'FileAllowance',
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
'authUserId': authUserId.toJson(),
|
||||||
|
'maxBytes': maxBytes,
|
||||||
|
if (note != null) 'note': note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJsonForProtocol() {
|
||||||
|
return {
|
||||||
|
'__className__': 'FileAllowance',
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
'authUserId': authUserId.toJson(),
|
||||||
|
'maxBytes': maxBytes,
|
||||||
|
if (note != null) 'note': note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return _isc.SerializationManager.encode(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Undefined {}
|
||||||
|
|
||||||
|
class _FileAllowanceImpl extends FileAllowance {
|
||||||
|
_FileAllowanceImpl({
|
||||||
|
int? id,
|
||||||
|
required _isc.UuidValue authUserId,
|
||||||
|
required int maxBytes,
|
||||||
|
String? note,
|
||||||
|
}) : super._(
|
||||||
|
id: id,
|
||||||
|
authUserId: authUserId,
|
||||||
|
maxBytes: maxBytes,
|
||||||
|
note: note,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [FileAllowance]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
@override
|
||||||
|
FileAllowance copyWith({
|
||||||
|
Object? id = _Undefined,
|
||||||
|
_isc.UuidValue? authUserId,
|
||||||
|
int? maxBytes,
|
||||||
|
Object? note = _Undefined,
|
||||||
|
}) {
|
||||||
|
return FileAllowance(
|
||||||
|
id: id is int? ? id : this.id,
|
||||||
|
authUserId: authUserId ?? this.authUserId,
|
||||||
|
maxBytes: maxBytes ?? this.maxBytes,
|
||||||
|
note: note is String? ? note : this.note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||||
|
/* To generate run: "serverpod generate" */
|
||||||
|
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// ignore_for_file: non_constant_identifier_names
|
||||||
|
// ignore_for_file: public_member_api_docs
|
||||||
|
// ignore_for_file: type_literal_in_constant_pattern
|
||||||
|
// ignore_for_file: use_super_parameters
|
||||||
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
|
|
||||||
|
/// A file doesn't fit: it would take the caller past what they may keep, or the
|
||||||
|
/// instance past what it keeps in all. Nothing was stored.
|
||||||
|
abstract class FileLimitExceededException
|
||||||
|
implements
|
||||||
|
_isc.SerializableException,
|
||||||
|
_isc.SerializableModel,
|
||||||
|
_isc.ProtocolSerialization {
|
||||||
|
FileLimitExceededException._({
|
||||||
|
required this.instanceFull,
|
||||||
|
required this.limitBytes,
|
||||||
|
required this.usedBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FileLimitExceededException({
|
||||||
|
required bool instanceFull,
|
||||||
|
required int limitBytes,
|
||||||
|
required int usedBytes,
|
||||||
|
}) = _FileLimitExceededExceptionImpl;
|
||||||
|
|
||||||
|
factory FileLimitExceededException.fromJson(
|
||||||
|
Map<String, dynamic> jsonSerialization,
|
||||||
|
) {
|
||||||
|
return FileLimitExceededException(
|
||||||
|
instanceFull: _isc.BoolJsonExtension.fromJson(
|
||||||
|
jsonSerialization['instanceFull'],
|
||||||
|
),
|
||||||
|
limitBytes: jsonSerialization['limitBytes'] as int,
|
||||||
|
usedBytes: jsonSerialization['usedBytes'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the instance is full, rather than the caller's own room.
|
||||||
|
bool instanceFull;
|
||||||
|
|
||||||
|
/// What the caller may keep, in bytes.
|
||||||
|
int limitBytes;
|
||||||
|
|
||||||
|
/// What the caller keeps now, in bytes.
|
||||||
|
int usedBytes;
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [FileLimitExceededException]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
FileLimitExceededException copyWith({
|
||||||
|
bool? instanceFull,
|
||||||
|
int? limitBytes,
|
||||||
|
int? usedBytes,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'__className__': 'FileLimitExceededException',
|
||||||
|
'instanceFull': instanceFull,
|
||||||
|
'limitBytes': limitBytes,
|
||||||
|
'usedBytes': usedBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJsonForProtocol() {
|
||||||
|
return {
|
||||||
|
'__className__': 'FileLimitExceededException',
|
||||||
|
'instanceFull': instanceFull,
|
||||||
|
'limitBytes': limitBytes,
|
||||||
|
'usedBytes': usedBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'FileLimitExceededException(instanceFull: $instanceFull, limitBytes: $limitBytes, usedBytes: $usedBytes)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileLimitExceededExceptionImpl extends FileLimitExceededException {
|
||||||
|
_FileLimitExceededExceptionImpl({
|
||||||
|
required bool instanceFull,
|
||||||
|
required int limitBytes,
|
||||||
|
required int usedBytes,
|
||||||
|
}) : super._(
|
||||||
|
instanceFull: instanceFull,
|
||||||
|
limitBytes: limitBytes,
|
||||||
|
usedBytes: usedBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [FileLimitExceededException]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
@override
|
||||||
|
FileLimitExceededException copyWith({
|
||||||
|
bool? instanceFull,
|
||||||
|
int? limitBytes,
|
||||||
|
int? usedBytes,
|
||||||
|
}) {
|
||||||
|
return FileLimitExceededException(
|
||||||
|
instanceFull: instanceFull ?? this.instanceFull,
|
||||||
|
limitBytes: limitBytes ?? this.limitBytes,
|
||||||
|
usedBytes: usedBytes ?? this.usedBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
halkyon_learn_client/lib/src/protocol/asset/file_usage.dart
Normal file
95
halkyon_learn_client/lib/src/protocol/asset/file_usage.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||||
|
/* To generate run: "serverpod generate" */
|
||||||
|
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// ignore_for_file: non_constant_identifier_names
|
||||||
|
// ignore_for_file: public_member_api_docs
|
||||||
|
// ignore_for_file: type_literal_in_constant_pattern
|
||||||
|
// ignore_for_file: use_super_parameters
|
||||||
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
|
|
||||||
|
/// How much room the caller's files take, and how much they may take.
|
||||||
|
abstract class FileUsage
|
||||||
|
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||||
|
FileUsage._({
|
||||||
|
required this.usedBytes,
|
||||||
|
required this.limitBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FileUsage({
|
||||||
|
required int usedBytes,
|
||||||
|
required int limitBytes,
|
||||||
|
}) = _FileUsageImpl;
|
||||||
|
|
||||||
|
factory FileUsage.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
return FileUsage(
|
||||||
|
usedBytes: jsonSerialization['usedBytes'] as int,
|
||||||
|
limitBytes: jsonSerialization['limitBytes'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What their files take now, in bytes.
|
||||||
|
int usedBytes;
|
||||||
|
|
||||||
|
/// What they may keep, in bytes: their own limit, or less when the instance
|
||||||
|
/// has less room left.
|
||||||
|
int limitBytes;
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [FileUsage]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
FileUsage copyWith({
|
||||||
|
int? usedBytes,
|
||||||
|
int? limitBytes,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'__className__': 'FileUsage',
|
||||||
|
'usedBytes': usedBytes,
|
||||||
|
'limitBytes': limitBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJsonForProtocol() {
|
||||||
|
return {
|
||||||
|
'__className__': 'FileUsage',
|
||||||
|
'usedBytes': usedBytes,
|
||||||
|
'limitBytes': limitBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return _isc.SerializationManager.encode(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileUsageImpl extends FileUsage {
|
||||||
|
_FileUsageImpl({
|
||||||
|
required int usedBytes,
|
||||||
|
required int limitBytes,
|
||||||
|
}) : super._(
|
||||||
|
usedBytes: usedBytes,
|
||||||
|
limitBytes: limitBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [FileUsage]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
@override
|
||||||
|
FileUsage copyWith({
|
||||||
|
int? usedBytes,
|
||||||
|
int? limitBytes,
|
||||||
|
}) {
|
||||||
|
return FileUsage(
|
||||||
|
usedBytes: usedBytes ?? this.usedBytes,
|
||||||
|
limitBytes: limitBytes ?? this.limitBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
import 'dart:async' as _ida;
|
import 'dart:async' as _ida;
|
||||||
import 'dart:typed_data' as _idt;
|
import 'dart:typed_data' as _idt;
|
||||||
|
import 'package:halkyon_learn_client/src/protocol/asset/file_usage.dart'
|
||||||
|
as _iimptq0q;
|
||||||
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
|
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
|
||||||
as _ieqh4yr9;
|
as _ieqh4yr9;
|
||||||
import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart'
|
import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart'
|
||||||
|
|
@ -47,7 +49,8 @@ class EndpointAsset extends _isc.EndpointRef {
|
||||||
String get name => 'asset';
|
String get name => 'asset';
|
||||||
|
|
||||||
/// How to upload the caller's file with [sha256] (lowercase hex) of
|
/// How to upload the caller's file with [sha256] (lowercase hex) of
|
||||||
/// [byteSize] bytes; null when the server has it already.
|
/// [byteSize] bytes; null when the server has it already. Throws a
|
||||||
|
/// [FileLimitExceededException] when it doesn't fit (see [FileLimits]).
|
||||||
_ida.Future<String?> startUpload(
|
_ida.Future<String?> startUpload(
|
||||||
String sha256,
|
String sha256,
|
||||||
int byteSize,
|
int byteSize,
|
||||||
|
|
@ -61,7 +64,8 @@ class EndpointAsset extends _isc.EndpointRef {
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Completes the upload of the caller's file with [sha256]; whether it is
|
/// Completes the upload of the caller's file with [sha256]; whether it is
|
||||||
/// stored now. A file whose content has another hash is deleted.
|
/// stored now. A file whose content has another hash is deleted, and so is
|
||||||
|
/// one that no longer fits, with a [FileLimitExceededException].
|
||||||
_ida.Future<bool> finishUpload(String sha256) =>
|
_ida.Future<bool> finishUpload(String sha256) =>
|
||||||
caller.callServerEndpoint<bool>(
|
caller.callServerEndpoint<bool>(
|
||||||
'asset',
|
'asset',
|
||||||
|
|
@ -69,6 +73,14 @@ class EndpointAsset extends _isc.EndpointRef {
|
||||||
{'sha256': sha256},
|
{'sha256': sha256},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// How much room the caller's files take, and may take.
|
||||||
|
_ida.Future<_iimptq0q.FileUsage> usage() =>
|
||||||
|
caller.callServerEndpoint<_iimptq0q.FileUsage>(
|
||||||
|
'asset',
|
||||||
|
'usage',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
/// The file of the asset [assetId]. Throws a [NotFoundException] when the
|
/// The file of the asset [assetId]. Throws a [NotFoundException] when the
|
||||||
/// asset isn't in a space the caller may read, or its file isn't uploaded
|
/// asset isn't in a space the caller may read, or its file isn't uploaded
|
||||||
/// yet.
|
/// yet.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import 'package:serverpod_database/serverpod_database.dart' as _isd;
|
||||||
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart'
|
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart'
|
||||||
as _ipulbpi2;
|
as _ipulbpi2;
|
||||||
import 'asset/asset.dart' as _ipb7vkxr;
|
import 'asset/asset.dart' as _ipb7vkxr;
|
||||||
|
import 'asset/file_allowance.dart' as _i9uv0f1n;
|
||||||
|
import 'asset/file_limit_exceeded_exception.dart' as _it5d94hq;
|
||||||
|
import 'asset/file_usage.dart' as _iurwo9k8;
|
||||||
import 'auth/device.dart' as _i6c5ny7n;
|
import 'auth/device.dart' as _i6c5ny7n;
|
||||||
import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5;
|
import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5;
|
||||||
import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n;
|
import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n;
|
||||||
|
|
@ -35,6 +38,9 @@ import 'studyset/studyset.dart' as _ikbclmae;
|
||||||
import 'tag/card_tag.dart' as _i6jz6nyq;
|
import 'tag/card_tag.dart' as _i6jz6nyq;
|
||||||
import 'tag/tag.dart' as _ie8t3ar4;
|
import 'tag/tag.dart' as _ie8t3ar4;
|
||||||
export 'asset/asset.dart';
|
export 'asset/asset.dart';
|
||||||
|
export 'asset/file_allowance.dart';
|
||||||
|
export 'asset/file_limit_exceeded_exception.dart';
|
||||||
|
export 'asset/file_usage.dart';
|
||||||
export 'auth/device.dart';
|
export 'auth/device.dart';
|
||||||
export 'auth/github_sign_in_refusal.dart';
|
export 'auth/github_sign_in_refusal.dart';
|
||||||
export 'auth/github_sign_in_refused_exception.dart';
|
export 'auth/github_sign_in_refused_exception.dart';
|
||||||
|
|
@ -885,6 +891,15 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
if (t == _ipb7vkxr.Asset) {
|
if (t == _ipb7vkxr.Asset) {
|
||||||
return _ipb7vkxr.Asset.fromJson(data) as T;
|
return _ipb7vkxr.Asset.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
if (t == _i9uv0f1n.FileAllowance) {
|
||||||
|
return _i9uv0f1n.FileAllowance.fromJson(data) as T;
|
||||||
|
}
|
||||||
|
if (t == _it5d94hq.FileLimitExceededException) {
|
||||||
|
return _it5d94hq.FileLimitExceededException.fromJson(data) as T;
|
||||||
|
}
|
||||||
|
if (t == _iurwo9k8.FileUsage) {
|
||||||
|
return _iurwo9k8.FileUsage.fromJson(data) as T;
|
||||||
|
}
|
||||||
if (t == _i6c5ny7n.Device) {
|
if (t == _i6c5ny7n.Device) {
|
||||||
return _i6c5ny7n.Device.fromJson(data) as T;
|
return _i6c5ny7n.Device.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
|
@ -924,6 +939,19 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
if (t == _isc.getType<_ipb7vkxr.Asset?>()) {
|
if (t == _isc.getType<_ipb7vkxr.Asset?>()) {
|
||||||
return (data != null ? _ipb7vkxr.Asset.fromJson(data) : null) as T;
|
return (data != null ? _ipb7vkxr.Asset.fromJson(data) : null) as T;
|
||||||
}
|
}
|
||||||
|
if (t == _isc.getType<_i9uv0f1n.FileAllowance?>()) {
|
||||||
|
return (data != null ? _i9uv0f1n.FileAllowance.fromJson(data) : null)
|
||||||
|
as T;
|
||||||
|
}
|
||||||
|
if (t == _isc.getType<_it5d94hq.FileLimitExceededException?>()) {
|
||||||
|
return (data != null
|
||||||
|
? _it5d94hq.FileLimitExceededException.fromJson(data)
|
||||||
|
: null)
|
||||||
|
as T;
|
||||||
|
}
|
||||||
|
if (t == _isc.getType<_iurwo9k8.FileUsage?>()) {
|
||||||
|
return (data != null ? _iurwo9k8.FileUsage.fromJson(data) : null) as T;
|
||||||
|
}
|
||||||
if (t == _isc.getType<_i6c5ny7n.Device?>()) {
|
if (t == _isc.getType<_i6c5ny7n.Device?>()) {
|
||||||
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
|
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
|
||||||
}
|
}
|
||||||
|
|
@ -993,6 +1021,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
static String? getClassNameForType(Type type) {
|
static String? getClassNameForType(Type type) {
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
_ipb7vkxr.Asset => 'Asset',
|
_ipb7vkxr.Asset => 'Asset',
|
||||||
|
_i9uv0f1n.FileAllowance => 'FileAllowance',
|
||||||
|
_it5d94hq.FileLimitExceededException => 'FileLimitExceededException',
|
||||||
|
_iurwo9k8.FileUsage => 'FileUsage',
|
||||||
_i6c5ny7n.Device => 'Device',
|
_i6c5ny7n.Device => 'Device',
|
||||||
_ir1gd9p5.GitHubSignInRefusal => 'GitHubSignInRefusal',
|
_ir1gd9p5.GitHubSignInRefusal => 'GitHubSignInRefusal',
|
||||||
_i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException',
|
_i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException',
|
||||||
|
|
@ -1024,6 +1055,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
switch (data) {
|
switch (data) {
|
||||||
case _ipb7vkxr.Asset():
|
case _ipb7vkxr.Asset():
|
||||||
return 'Asset';
|
return 'Asset';
|
||||||
|
case _i9uv0f1n.FileAllowance():
|
||||||
|
return 'FileAllowance';
|
||||||
|
case _it5d94hq.FileLimitExceededException():
|
||||||
|
return 'FileLimitExceededException';
|
||||||
|
case _iurwo9k8.FileUsage():
|
||||||
|
return 'FileUsage';
|
||||||
case _i6c5ny7n.Device():
|
case _i6c5ny7n.Device():
|
||||||
return 'Device';
|
return 'Device';
|
||||||
case _ir1gd9p5.GitHubSignInRefusal():
|
case _ir1gd9p5.GitHubSignInRefusal():
|
||||||
|
|
@ -1079,6 +1116,15 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
if (dataClassName == 'Asset') {
|
if (dataClassName == 'Asset') {
|
||||||
return deserialize<_ipb7vkxr.Asset>(data['data']);
|
return deserialize<_ipb7vkxr.Asset>(data['data']);
|
||||||
}
|
}
|
||||||
|
if (dataClassName == 'FileAllowance') {
|
||||||
|
return deserialize<_i9uv0f1n.FileAllowance>(data['data']);
|
||||||
|
}
|
||||||
|
if (dataClassName == 'FileLimitExceededException') {
|
||||||
|
return deserialize<_it5d94hq.FileLimitExceededException>(data['data']);
|
||||||
|
}
|
||||||
|
if (dataClassName == 'FileUsage') {
|
||||||
|
return deserialize<_iurwo9k8.FileUsage>(data['data']);
|
||||||
|
}
|
||||||
if (dataClassName == 'Device') {
|
if (dataClassName == 'Device') {
|
||||||
return deserialize<_i6c5ny7n.Device>(data['data']);
|
return deserialize<_i6c5ny7n.Device>(data['data']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -413,6 +413,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ankiImportMediaSubtitle": "Keep the deck's images and sounds on the cards.",
|
"ankiImportMediaSubtitle": "Keep the deck's images and sounds on the cards.",
|
||||||
|
"ankiImportMediaMayNotFit": "Its files take {size} MB, more than the {left} MB you have left: those that don't fit stay on this device, and your other devices won't show them.",
|
||||||
|
"@ankiImportMediaMayNotFit": {
|
||||||
|
"placeholders": {
|
||||||
|
"size": {"type": "String"},
|
||||||
|
"left": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ankiImportRunning": "Importing…",
|
"ankiImportRunning": "Importing…",
|
||||||
"ankiImportConfirm": "Import",
|
"ankiImportConfirm": "Import",
|
||||||
"ankiImportNotAPackage": "This isn't an Anki deck. In Anki, export a deck as an .apkg file and import that.",
|
"ankiImportNotAPackage": "This isn't an Anki deck. In Anki, export a deck as an .apkg file and import that.",
|
||||||
|
|
|
||||||
|
|
@ -2158,6 +2158,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Keep the deck\'s images and sounds on the cards.'**
|
/// **'Keep the deck\'s images and sounds on the cards.'**
|
||||||
String get ankiImportMediaSubtitle;
|
String get ankiImportMediaSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @ankiImportMediaMayNotFit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Its files take {size} MB, more than the {left} MB you have left: those that don\'t fit stay on this device, and your other devices won\'t show them.'**
|
||||||
|
String ankiImportMediaMayNotFit(String size, String left);
|
||||||
|
|
||||||
/// No description provided for @ankiImportRunning.
|
/// No description provided for @ankiImportRunning.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1272,6 +1272,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get ankiImportMediaSubtitle =>
|
String get ankiImportMediaSubtitle =>
|
||||||
'Keep the deck\'s images and sounds on the cards.';
|
'Keep the deck\'s images and sounds on the cards.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String ankiImportMediaMayNotFit(String size, String left) {
|
||||||
|
return 'Its files take $size MB, more than the $left MB you have left: those that don\'t fit stay on this device, and your other devices won\'t show them.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ankiImportRunning => 'Importing…';
|
String get ankiImportRunning => 'Importing…';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,11 @@ class AssetRepository(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await _files.clearPending(hash);
|
await _files.clearPending(hash);
|
||||||
|
} on FileLimitReached catch (error) {
|
||||||
|
// The others wouldn't fit either: they stay on this device, and
|
||||||
|
// the next round asks again, once room may have been made.
|
||||||
|
log('No room for more files', name: 'Assets', error: error);
|
||||||
|
return;
|
||||||
} on Object catch (error) {
|
} on Object catch (error) {
|
||||||
// Without a connection the others fail too: next time.
|
// Without a connection the others fail too: next time.
|
||||||
log('Uploading a file failed', name: 'Assets', error: error);
|
log('Uploading a file failed', name: 'Assets', error: error);
|
||||||
|
|
@ -167,6 +172,17 @@ class AssetRepository(
|
||||||
await _server.finishUpload(hash);
|
await _server.finishUpload(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How much room the person's files take on the server, and may take; null
|
||||||
|
/// when that can't be asked now (no connection).
|
||||||
|
Future<FileRoom?> room() async {
|
||||||
|
try {
|
||||||
|
return await _server.room();
|
||||||
|
} on Exception catch (error) {
|
||||||
|
log('Asking for the room of files failed', name: 'Assets', error: error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stops uploading; an upload under way finishes first.
|
/// Stops uploading; an upload under way finishes first.
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await _triggers.cancel();
|
await _triggers.cancel();
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,40 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
/// How much room the person's files take on the server, and may take, in
|
||||||
|
/// bytes.
|
||||||
|
typedef FileRoom = ({int usedBytes, int limitBytes});
|
||||||
|
|
||||||
|
/// The server keeps no more of the person's files: they would take more
|
||||||
|
/// room than the person may use, or the instance has left ([instanceFull]).
|
||||||
|
class const FileLimitReached({
|
||||||
|
/// Whether the instance is full, rather than the person's own room.
|
||||||
|
required final bool instanceFull,
|
||||||
|
|
||||||
|
/// What the person may keep, in bytes.
|
||||||
|
required final int limitBytes,
|
||||||
|
}) implements Exception {
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'FileLimitReached(instanceFull: $instanceFull, limit: $limitBytes)';
|
||||||
|
}
|
||||||
|
|
||||||
/// The server's copy of the person's files.
|
/// The server's copy of the person's files.
|
||||||
abstract interface class AssetServerGateway() {
|
abstract interface class AssetServerGateway() {
|
||||||
/// How to upload the file with [sha256] of [byteSize] bytes; null when the
|
/// How to upload the file with [sha256] of [byteSize] bytes; null when the
|
||||||
/// server has it already.
|
/// server has it already. Throws [FileLimitReached] when it doesn't fit.
|
||||||
Future<String?> startUpload(String sha256, int byteSize);
|
Future<String?> startUpload(String sha256, int byteSize);
|
||||||
|
|
||||||
/// Uploads [bytes] as [description] says; whether that worked.
|
/// Uploads [bytes] as [description] says; whether that worked.
|
||||||
Future<bool> upload(String description, Uint8List bytes);
|
Future<bool> upload(String description, Uint8List bytes);
|
||||||
|
|
||||||
/// Completes the upload of the file with [sha256]; whether the server keeps
|
/// Completes the upload of the file with [sha256]; whether the server keeps
|
||||||
/// it, which it does only if its content has that hash.
|
/// it, which it does only if its content has that hash. Throws
|
||||||
|
/// [FileLimitReached] when it no longer fits.
|
||||||
Future<bool> finishUpload(String sha256);
|
Future<bool> finishUpload(String sha256);
|
||||||
|
|
||||||
|
/// How much room the person's files take, and may take.
|
||||||
|
Future<FileRoom> room();
|
||||||
|
|
||||||
/// The file of the asset [id]; null when the server has none for this
|
/// The file of the asset [id]; null when the server has none for this
|
||||||
/// person (not uploaded yet, or not theirs).
|
/// person (not uploaded yet, or not theirs).
|
||||||
Future<Uint8List?> download(String id);
|
Future<Uint8List?> download(String id);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ class ServerpodAssetServerGateway(final protocol.Client _client)
|
||||||
implements AssetServerGateway {
|
implements AssetServerGateway {
|
||||||
@override
|
@override
|
||||||
Future<String?> startUpload(String sha256, int byteSize) =>
|
Future<String?> startUpload(String sha256, int byteSize) =>
|
||||||
_client.asset.startUpload(sha256, byteSize);
|
_refusing(() => _client.asset.startUpload(sha256, byteSize));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> upload(String description, Uint8List bytes) =>
|
Future<bool> upload(String description, Uint8List bytes) =>
|
||||||
|
|
@ -18,7 +18,25 @@ class ServerpodAssetServerGateway(final protocol.Client _client)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> finishUpload(String sha256) =>
|
Future<bool> finishUpload(String sha256) =>
|
||||||
_client.asset.finishUpload(sha256);
|
_refusing(() => _client.asset.finishUpload(sha256));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FileRoom> room() async {
|
||||||
|
final usage = await _client.asset.usage();
|
||||||
|
return (usedBytes: usage.usedBytes, limitBytes: usage.limitBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [call], its refusal for want of room as a [FileLimitReached].
|
||||||
|
Future<T> _refusing<T>(Future<T> Function() call) async {
|
||||||
|
try {
|
||||||
|
return await call();
|
||||||
|
} on protocol.FileLimitExceededException catch (error) {
|
||||||
|
throw FileLimitReached(
|
||||||
|
instanceFull: error.instanceFull,
|
||||||
|
limitBytes: error.limitBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List?> download(String id) async {
|
Future<Uint8List?> download(String id) async {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
|
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
|
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
|
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
|
||||||
|
|
@ -12,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.
|
||||||
class AnkiImportBloc(
|
class AnkiImportBloc(
|
||||||
final AnkiPackageReader _reader,
|
final AnkiPackageReader _reader,
|
||||||
final AnkiImporter _importer,
|
final AnkiImporter _importer,
|
||||||
|
final AssetRepository _assets,
|
||||||
final String _studysetId,
|
final String _studysetId,
|
||||||
) extends Bloc<AnkiImportEvent, AnkiImportState> {
|
) extends Bloc<AnkiImportEvent, AnkiImportState> {
|
||||||
this : super(const AnkiImportStateReading()) {
|
this : super(const AnkiImportStateReading()) {
|
||||||
|
|
@ -40,11 +42,20 @@ class AnkiImportBloc(
|
||||||
}
|
}
|
||||||
_package = package;
|
_package = package;
|
||||||
final collection = package.collection;
|
final collection = package.collection;
|
||||||
|
final media = referencedMedia(collection);
|
||||||
|
final room = media.isEmpty ? null : await _assets.room();
|
||||||
final summary = AnkiImportSummary(
|
final summary = AnkiImportSummary(
|
||||||
notes: collection.notes.length,
|
notes: collection.notes.length,
|
||||||
decks: collection.deckPaths.length,
|
decks: collection.deckPaths.length,
|
||||||
hasReviews: collection.hasReviews,
|
hasReviews: collection.hasReviews,
|
||||||
files: referencedMedia(collection).length,
|
files: media.length,
|
||||||
|
fileBytes: media.fold(
|
||||||
|
0,
|
||||||
|
(sum, name) => sum + (package.mediaSize(name) ?? 0),
|
||||||
|
),
|
||||||
|
roomLeft: room == null
|
||||||
|
? null
|
||||||
|
: (room.limitBytes - room.usedBytes).clamp(0, room.limitBytes),
|
||||||
);
|
);
|
||||||
emit(
|
emit(
|
||||||
AnkiImportStateReady(
|
AnkiImportStateReady(
|
||||||
|
|
@ -101,12 +112,31 @@ class const AnkiImportSummary({
|
||||||
|
|
||||||
/// How many images and sounds its notes show.
|
/// How many images and sounds its notes show.
|
||||||
required final int files,
|
required final int files,
|
||||||
|
|
||||||
|
/// How much room those take, in bytes.
|
||||||
|
final int fileBytes = 0,
|
||||||
|
|
||||||
|
/// How much room the person has left on the server, in bytes; null when
|
||||||
|
/// unknown (no connection, or no files to import).
|
||||||
|
final int? roomLeft,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// Whether its notes are in more than one deck.
|
/// Whether its notes are in more than one deck.
|
||||||
bool get hasSubdecks => decks > 1;
|
bool get hasSubdecks => decks > 1;
|
||||||
|
|
||||||
|
/// Whether its files would take more room than the person has left: those
|
||||||
|
/// that don't fit stay on this device. (Files they have already don't take
|
||||||
|
/// more, so it may fit nonetheless.)
|
||||||
|
bool get filesMayNotFit => roomLeft != null && fileBytes > roomLeft!;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [notes, decks, hasReviews, files];
|
List<Object?> get props => [
|
||||||
|
notes,
|
||||||
|
decks,
|
||||||
|
hasReviews,
|
||||||
|
files,
|
||||||
|
fileBytes,
|
||||||
|
roomLeft,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How to import a package.
|
/// How to import a package.
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,15 @@ class AnkiPackage._(
|
||||||
final Map<String, _MediaEntry> _media,
|
final Map<String, _MediaEntry> _media,
|
||||||
final bool _compressed,
|
final bool _compressed,
|
||||||
) {
|
) {
|
||||||
|
/// The size of the media file [name] once decoded, in bytes; null if the
|
||||||
|
/// package doesn't ship it.
|
||||||
|
int? mediaSize(String name) {
|
||||||
|
final entry = _media[name];
|
||||||
|
return entry == null
|
||||||
|
? null
|
||||||
|
: entry.size ?? _archive.findFile(entry.zipName)?.size;
|
||||||
|
}
|
||||||
|
|
||||||
/// The media file [name] as the notes refer to it; null if the package
|
/// The media file [name] as the notes refer to it; null if the package
|
||||||
/// doesn't ship it or it is damaged.
|
/// doesn't ship it or it is damaged.
|
||||||
Uint8List? mediaBytes(String name) {
|
Uint8List? mediaBytes(String name) {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ Future<void> importFromAnki(
|
||||||
person.reviews,
|
person.reviews,
|
||||||
person.assets,
|
person.assets,
|
||||||
),
|
),
|
||||||
|
person.assets,
|
||||||
studysetId,
|
studysetId,
|
||||||
)..add(AnkiImportEventStarted(file.bytes)),
|
)..add(AnkiImportEventStarted(file.bytes)),
|
||||||
child: const _AnkiImport(),
|
child: const _AnkiImport(),
|
||||||
|
|
@ -221,7 +222,14 @@ class const _Options({
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
change(options.copyWith(includeMedia: value ?? false)),
|
change(options.copyWith(includeMedia: value ?? false)),
|
||||||
title: Text(l10n.ankiImportMedia(summary.files)),
|
title: Text(l10n.ankiImportMedia(summary.files)),
|
||||||
subtitle: Text(l10n.ankiImportMediaSubtitle),
|
subtitle: Text(
|
||||||
|
options.includeMedia && summary.filesMayNotFit
|
||||||
|
? l10n.ankiImportMediaMayNotFit(
|
||||||
|
_megabytes(summary.fileBytes),
|
||||||
|
_megabytes(summary.roomLeft!),
|
||||||
|
)
|
||||||
|
: l10n.ankiImportMediaSubtitle,
|
||||||
|
),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
),
|
),
|
||||||
|
|
@ -240,3 +248,6 @@ class const _Options({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [bytes] in megabytes, one decimal.
|
||||||
|
String _megabytes(int bytes) => (bytes / (1024 * 1024)).toStringAsFixed(1);
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,40 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('keeps waiting while the server has no room, and asks no '
|
||||||
|
'further that round', () async {
|
||||||
|
await addImage();
|
||||||
|
await repository.add(
|
||||||
|
Uint8List.fromList(utf8.encode('another image')),
|
||||||
|
fileName: 'dog.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
);
|
||||||
|
server.limitBytes = 4;
|
||||||
|
|
||||||
|
await repository.uploadPending();
|
||||||
|
|
||||||
|
expect(server.files, isEmpty);
|
||||||
|
expect(cache.pending, hasLength(2));
|
||||||
|
expect(server.started, hasLength(1));
|
||||||
|
|
||||||
|
server.limitBytes = 1 << 20;
|
||||||
|
await repository.uploadPending();
|
||||||
|
expect(cache.pending, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tells how much room the files take, and may take', () async {
|
||||||
|
server.files[imageHash] = image;
|
||||||
|
server.limitBytes = 100;
|
||||||
|
|
||||||
|
expect(await repository.room(), (
|
||||||
|
usedBytes: image.length,
|
||||||
|
limitBytes: 100,
|
||||||
|
));
|
||||||
|
|
||||||
|
server.failCalls = Exception('offline');
|
||||||
|
expect(await repository.room(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
test('stops waiting for a file gone from this device', () async {
|
test('stops waiting for a file gone from this device', () async {
|
||||||
await addImage();
|
await addImage();
|
||||||
cache.files.clear();
|
cache.files.clear();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
import '../../../support/fake_asset_server_gateway.dart';
|
||||||
import '../../../support/fake_card_gateway.dart';
|
import '../../../support/fake_card_gateway.dart';
|
||||||
import '../../../support/fake_file_picker.dart';
|
import '../../../support/fake_file_picker.dart';
|
||||||
import '../../../support/fake_studyset_gateway.dart';
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
|
@ -17,10 +18,12 @@ import '../data/anki_package_test.dart' show fixture;
|
||||||
void main() {
|
void main() {
|
||||||
late FakeCardGateway cardGateway;
|
late FakeCardGateway cardGateway;
|
||||||
late FakeFilePicker files;
|
late FakeFilePicker files;
|
||||||
|
late FakeAssetServerGateway server;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
cardGateway = FakeCardGateway();
|
cardGateway = FakeCardGateway();
|
||||||
files = FakeFilePicker();
|
files = FakeFilePicker();
|
||||||
|
server = FakeAssetServerGateway();
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Opens the studyset Japanese, in a window wide enough for Add material
|
/// Opens the studyset Japanese, in a window wide enough for Add material
|
||||||
|
|
@ -39,6 +42,7 @@ void main() {
|
||||||
),
|
),
|
||||||
cardGateway: cardGateway,
|
cardGateway: cardGateway,
|
||||||
files: files,
|
files: files,
|
||||||
|
assetServer: server,
|
||||||
),
|
),
|
||||||
location: RoutePaths.studyset('japanese'),
|
location: RoutePaths.studyset('japanese'),
|
||||||
);
|
);
|
||||||
|
|
@ -86,6 +90,21 @@ void main() {
|
||||||
expect(await cards.cardsOf('japanese'), hasLength(4));
|
expect(await cards.cardsOf('japanese'), hasLength(4));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('warns when the files may not fit in the room left', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
server.limitBytes = 100;
|
||||||
|
|
||||||
|
await startImport(tester, fixture('anki_latest.apkg'));
|
||||||
|
|
||||||
|
expect(find.textContaining('more than the'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Include media (2 files)'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.textContaining('more than the'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('says why a file is no deck it can import', (tester) async {
|
testWidgets('says why a file is no deck it can import', (tester) async {
|
||||||
await startImport(tester, Uint8List.fromList([1, 2, 3]));
|
await startImport(tester, Uint8List.fromList([1, 2, 3]));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,23 @@ class FakeAssetServerGateway() implements AssetServerGateway {
|
||||||
/// When set, uploads arrive with this content instead of what was sent.
|
/// When set, uploads arrive with this content instead of what was sent.
|
||||||
Uint8List? corruptUploads;
|
Uint8List? corruptUploads;
|
||||||
|
|
||||||
|
/// What the person may keep, in bytes; past it, uploads are refused.
|
||||||
|
int limitBytes = 1 << 30;
|
||||||
|
|
||||||
|
int get _usedBytes =>
|
||||||
|
files.values.fold(0, (sum, bytes) => sum + bytes.length);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String?> startUpload(String sha256, int byteSize) async {
|
Future<String?> startUpload(String sha256, int byteSize) async {
|
||||||
_check();
|
_check();
|
||||||
started.add(sha256);
|
started.add(sha256);
|
||||||
return files.containsKey(sha256) ? null : 'upload:$sha256';
|
if (files.containsKey(sha256)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_usedBytes + byteSize > limitBytes) {
|
||||||
|
throw FileLimitReached(instanceFull: false, limitBytes: limitBytes);
|
||||||
|
}
|
||||||
|
return 'upload:$sha256';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -60,6 +72,12 @@ class FakeAssetServerGateway() implements AssetServerGateway {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FileRoom> room() async {
|
||||||
|
_check();
|
||||||
|
return (usedBytes: _usedBytes, limitBytes: limitBytes);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List?> download(String id) async {
|
Future<Uint8List?> download(String id) async {
|
||||||
_check();
|
_check();
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue