feat(explore): how many learn it here, opt-in; copies published within their licence

Explore says how many people learn a published studyset on this instance
(ADR 0005, rule 8): shown, never ranked, counted daily by the server from
the people with an identity who chose to be counted (Settings, Privacy;
settings schema 8) and studied a copy on 3 or more days of the last 30,
rounded down from 5 on, so that nobody can be told apart. Only a number
the instance computes itself counts; votes count for nothing. The author
sees the same rounded number in the Publish sheet.

A copy of a published studyset is published within the original's
licence: never under "no derivatives", under a licence that keeps
adaptations under it only under that one, in every studyset of the copy.
Its releases say what they are based on, and Explore shows it.

ADR 0005 gets an addendum with stage 3's decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-10 00:18:29 +02:00
commit 7574e42415
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
63 changed files with 26176 additions and 30 deletions

View file

@ -158,7 +158,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
- **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own; `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it as a form, for people with an identity.
- **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back.
- **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is the old app's feed: cards with title, author, the start of the description, the latest release, what it holds, the licence and Add to library (its progress, then In your library, opening the copy); a summary sheet with all of it.
- **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. **"Learn this here"** (ADR 0005, rule 8): `LearnerCountFutureCall`, daily, counts per published studyset the people of this instance with the `identified` scope who chose to be counted (`countLearning` in their settings, settings schema 8, off unless switched on in Settings › Privacy) and studied a copy of it (a studyset whose payload names it) on 3 or more days of the last 30, fresh starts left out; payloads are decoded in Dart, so one that isn't JSON counts for nothing rather than failing the count. Only `learnersShown` (rounded down: 5, 10, 20, 50, …; none below 5) ever leaves the server, in Explore and the author's Publish sheet. **A copy is published within its licence** (rule 10): the server looks up the original's (`PublishRefusal.forkForbidden` under "no derivatives"; `licenseMustStay` under a licence that keeps adaptations under it, `keepsAdaptations`, in every studyset of the copy), and the release keeps what it is based on (`basedOn`, shown in Explore); a person publishing a copy of their own is its author, and the copy of another instance's studyset goes with its location alone. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is the old app's feed: cards with title, author, the start of the description, the latest release, what it holds, the licence and Add to library (its progress, then In your library, opening the copy); a summary sheet with all of it.
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
- **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.

View file

@ -71,6 +71,28 @@ requests behind a gateway. This ADR decides it, and the order it is built in.
to fetch it, and travels over HTTPS (stage 5), never inside an activity; no core
function depends on federation. halky.one may later index releases, never host them.
## Addendum: stage 3, Explore and learning from a release (2026-10-09)
8. **Explore finds by fit, never by popularity.** It lists what is published by search,
languages and kinds of content, the newest release first, a page at a time, the next
only when asked for. Votes (ActivityPub likes) cost nothing to fake, so they count for
nothing. Learning activity is harder to fake and is **shown, never ranked**: "10+ learn
this here", counting only people of *this* instance (a number another instance sends
could be anything), with an identity, who chose to be counted (opt-in), and who
studied a copy on 3 or more days of the last 30; shown rounded, from 5 on, so that
nobody can be told apart. Ranking by it would keep the first good studyset on top for
good.
9. **The app makes and updates a copy; the server never writes into a learner's
space.** A copy is written through the learner's own repositories, as an import; each
item names its origin (publication, item, release). A later release is offered on the
copy with what it brings and taken at a tap: per side for cards, notes and steps (as
rule 4 says), per field for studysets, paths and PDFs; withdrawn items stay, marked and
studied on; what the learner deleted stays deleted.
10. **A copy may be published within its licence:** never under "no derivatives", under a
ShareAlike licence only under that licence; it says what it is based on. An author can
take a studyset off Explore again: nobody can add it then, and copies get no updates;
its releases stay.
## Consequences
- An instance's operator chooses an account domain once, and keeps WebFinger answering

View file

@ -30,6 +30,7 @@ abstract class ExploreEntry
this.description,
this.frontLanguage,
this.backLanguage,
this.basedOn,
required this.license,
this.licenseHolder,
this.licenseYear,
@ -38,6 +39,7 @@ abstract class ExploreEntry
required this.noteCount,
required this.pathCount,
required this.pdfCount,
this.learners,
});
factory ExploreEntry({
@ -54,6 +56,7 @@ abstract class ExploreEntry
String? description,
String? frontLanguage,
String? backLanguage,
String? basedOn,
required String license,
String? licenseHolder,
int? licenseYear,
@ -62,6 +65,7 @@ abstract class ExploreEntry
required int noteCount,
required int pathCount,
required int pdfCount,
int? learners,
}) = _ExploreEntryImpl;
factory ExploreEntry.fromJson(Map<String, dynamic> jsonSerialization) {
@ -83,6 +87,7 @@ abstract class ExploreEntry
description: jsonSerialization['description'] as String?,
frontLanguage: jsonSerialization['frontLanguage'] as String?,
backLanguage: jsonSerialization['backLanguage'] as String?,
basedOn: jsonSerialization['basedOn'] as String?,
license: jsonSerialization['license'] as String,
licenseHolder: jsonSerialization['licenseHolder'] as String?,
licenseYear: jsonSerialization['licenseYear'] as int?,
@ -91,6 +96,7 @@ abstract class ExploreEntry
noteCount: jsonSerialization['noteCount'] as int,
pathCount: jsonSerialization['pathCount'] as int,
pdfCount: jsonSerialization['pdfCount'] as int,
learners: jsonSerialization['learners'] as int?,
);
}
@ -126,6 +132,9 @@ abstract class ExploreEntry
String? backLanguage;
/// Where the published studyset it is a copy of is, for a copy.
String? basedOn;
/// Its licence and credits.
String license;
@ -144,6 +153,10 @@ abstract class ExploreEntry
int pdfCount;
/// How many people learn it here, rounded down (5, 10, 20, 50, 100, …);
/// null for fewer than 5, so that nobody can be told apart.
int? learners;
/// Returns a shallow copy of this [ExploreEntry]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@ -161,6 +174,7 @@ abstract class ExploreEntry
String? description,
String? frontLanguage,
String? backLanguage,
String? basedOn,
String? license,
String? licenseHolder,
int? licenseYear,
@ -169,6 +183,7 @@ abstract class ExploreEntry
int? noteCount,
int? pathCount,
int? pdfCount,
int? learners,
});
@override
Map<String, dynamic> toJson() {
@ -187,6 +202,7 @@ abstract class ExploreEntry
if (description != null) 'description': description,
if (frontLanguage != null) 'frontLanguage': frontLanguage,
if (backLanguage != null) 'backLanguage': backLanguage,
if (basedOn != null) 'basedOn': basedOn,
'license': license,
if (licenseHolder != null) 'licenseHolder': licenseHolder,
if (licenseYear != null) 'licenseYear': licenseYear,
@ -195,6 +211,7 @@ abstract class ExploreEntry
'noteCount': noteCount,
'pathCount': pathCount,
'pdfCount': pdfCount,
if (learners != null) 'learners': learners,
};
}
@ -215,6 +232,7 @@ abstract class ExploreEntry
if (description != null) 'description': description,
if (frontLanguage != null) 'frontLanguage': frontLanguage,
if (backLanguage != null) 'backLanguage': backLanguage,
if (basedOn != null) 'basedOn': basedOn,
'license': license,
if (licenseHolder != null) 'licenseHolder': licenseHolder,
if (licenseYear != null) 'licenseYear': licenseYear,
@ -223,6 +241,7 @@ abstract class ExploreEntry
'noteCount': noteCount,
'pathCount': pathCount,
'pdfCount': pdfCount,
if (learners != null) 'learners': learners,
};
}
@ -249,6 +268,7 @@ class _ExploreEntryImpl extends ExploreEntry {
String? description,
String? frontLanguage,
String? backLanguage,
String? basedOn,
required String license,
String? licenseHolder,
int? licenseYear,
@ -257,6 +277,7 @@ class _ExploreEntryImpl extends ExploreEntry {
required int noteCount,
required int pathCount,
required int pdfCount,
int? learners,
}) : super._(
publicId: publicId,
location: location,
@ -271,6 +292,7 @@ class _ExploreEntryImpl extends ExploreEntry {
description: description,
frontLanguage: frontLanguage,
backLanguage: backLanguage,
basedOn: basedOn,
license: license,
licenseHolder: licenseHolder,
licenseYear: licenseYear,
@ -279,6 +301,7 @@ class _ExploreEntryImpl extends ExploreEntry {
noteCount: noteCount,
pathCount: pathCount,
pdfCount: pdfCount,
learners: learners,
);
/// Returns a shallow copy of this [ExploreEntry]
@ -299,6 +322,7 @@ class _ExploreEntryImpl extends ExploreEntry {
Object? description = _Undefined,
Object? frontLanguage = _Undefined,
Object? backLanguage = _Undefined,
Object? basedOn = _Undefined,
String? license,
Object? licenseHolder = _Undefined,
Object? licenseYear = _Undefined,
@ -307,6 +331,7 @@ class _ExploreEntryImpl extends ExploreEntry {
int? noteCount,
int? pathCount,
int? pdfCount,
Object? learners = _Undefined,
}) {
return ExploreEntry(
publicId: publicId ?? this.publicId,
@ -326,6 +351,7 @@ class _ExploreEntryImpl extends ExploreEntry {
? frontLanguage
: this.frontLanguage,
backLanguage: backLanguage is String? ? backLanguage : this.backLanguage,
basedOn: basedOn is String? ? basedOn : this.basedOn,
license: license ?? this.license,
licenseHolder: licenseHolder is String?
? licenseHolder
@ -336,6 +362,7 @@ class _ExploreEntryImpl extends ExploreEntry {
noteCount: noteCount ?? this.noteCount,
pathCount: pathCount ?? this.pathCount,
pdfCount: pdfCount ?? this.pdfCount,
learners: learners is int? ? learners : this.learners,
);
}
}

View file

@ -25,6 +25,7 @@ abstract class Publication
required this.location,
required this.releases,
required this.withdrawn,
this.learners,
});
factory Publication({
@ -34,6 +35,7 @@ abstract class Publication
required String location,
required List<_ia42v78b.ReleaseInfo> releases,
required bool withdrawn,
int? learners,
}) = _PublicationImpl;
factory Publication.fromJson(Map<String, dynamic> jsonSerialization) {
@ -52,6 +54,7 @@ abstract class Publication
withdrawn: _isc.BoolJsonExtension.fromJson(
jsonSerialization['withdrawn'],
),
learners: jsonSerialization['learners'] as int?,
);
}
@ -72,6 +75,10 @@ abstract class Publication
/// Whether its author took it off Explore.
bool withdrawn;
/// How many people learn it here, rounded as Explore shows it; null for
/// fewer than 5.
int? learners;
/// Returns a shallow copy of this [Publication]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@ -82,6 +89,7 @@ abstract class Publication
String? location,
List<_ia42v78b.ReleaseInfo>? releases,
bool? withdrawn,
int? learners,
});
@override
Map<String, dynamic> toJson() {
@ -93,6 +101,7 @@ abstract class Publication
'location': location,
'releases': releases.toJson(valueToJson: (v) => v.toJson()),
'withdrawn': withdrawn,
if (learners != null) 'learners': learners,
};
}
@ -106,6 +115,7 @@ abstract class Publication
'location': location,
'releases': releases.toJson(valueToJson: (v) => v.toJsonForProtocol()),
'withdrawn': withdrawn,
if (learners != null) 'learners': learners,
};
}
@ -115,6 +125,8 @@ abstract class Publication
}
}
class _Undefined {}
class _PublicationImpl extends Publication {
_PublicationImpl({
required _isc.UuidValue publicId,
@ -123,6 +135,7 @@ class _PublicationImpl extends Publication {
required String location,
required List<_ia42v78b.ReleaseInfo> releases,
required bool withdrawn,
int? learners,
}) : super._(
publicId: publicId,
studysetId: studysetId,
@ -130,6 +143,7 @@ class _PublicationImpl extends Publication {
location: location,
releases: releases,
withdrawn: withdrawn,
learners: learners,
);
/// Returns a shallow copy of this [Publication]
@ -143,6 +157,7 @@ class _PublicationImpl extends Publication {
String? location,
List<_ia42v78b.ReleaseInfo>? releases,
bool? withdrawn,
Object? learners = _Undefined,
}) {
return Publication(
publicId: publicId ?? this.publicId,
@ -151,6 +166,7 @@ class _PublicationImpl extends Publication {
location: location ?? this.location,
releases: releases ?? this.releases.map((e0) => e0.copyWith()).toList(),
withdrawn: withdrawn ?? this.withdrawn,
learners: learners is int? ? learners : this.learners,
);
}
}

View file

@ -47,7 +47,15 @@ enum PublishRefusal implements _isc.SerializableModel {
slugFixed,
/// Publishing that studyset is already under way.
busy;
busy,
/// It is a copy of a published studyset whose licence forbids passing on
/// adaptations ("no derivatives"): only its author publishes it.
forkForbidden,
/// It is a copy of a published studyset under a licence that asks
/// adaptations to stay under it (ShareAlike): `license` says which.
licenseMustStay;
static PublishRefusal fromJson(String name) {
switch (name) {
@ -71,6 +79,10 @@ enum PublishRefusal implements _isc.SerializableModel {
return PublishRefusal.slugFixed;
case 'busy':
return PublishRefusal.busy;
case 'forkForbidden':
return PublishRefusal.forkForbidden;
case 'licenseMustStay':
return PublishRefusal.licenseMustStay;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "PublishRefusal"',

View file

@ -23,11 +23,13 @@ abstract class PublishRefusedException
PublishRefusedException._({
required this.reason,
this.items,
this.license,
});
factory PublishRefusedException({
required _i5rbkflk.PublishRefusal reason,
List<_isc.UuidValue>? items,
String? license,
}) = _PublishRefusedExceptionImpl;
factory PublishRefusedException.fromJson(
@ -42,6 +44,7 @@ abstract class PublishRefusedException
: _itx5j3f5.Protocol().deserialize<List<_isc.UuidValue>>(
jsonSerialization['items'],
),
license: jsonSerialization['license'] as String?,
);
}
@ -52,12 +55,16 @@ abstract class PublishRefusedException
/// `licenseNotOpen`, the studysets whose licence doesn't allow it.
List<_isc.UuidValue>? items;
/// With `licenseMustStay`, the licence the copy must keep.
String? license;
/// Returns a shallow copy of this [PublishRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
PublishRefusedException copyWith({
_i5rbkflk.PublishRefusal? reason,
List<_isc.UuidValue>? items,
String? license,
});
@override
Map<String, dynamic> toJson() {
@ -65,6 +72,7 @@ abstract class PublishRefusedException
'__className__': 'PublishRefusedException',
'reason': reason.toJson(),
if (items != null) 'items': items?.toJson(valueToJson: (v) => v.toJson()),
if (license != null) 'license': license,
};
}
@ -74,12 +82,13 @@ abstract class PublishRefusedException
'__className__': 'PublishRefusedException',
'reason': reason.toJson(),
if (items != null) 'items': items?.toJson(valueToJson: (v) => v.toJson()),
if (license != null) 'license': license,
};
}
@override
String toString() {
return 'PublishRefusedException(reason: $reason, items: $items)';
return 'PublishRefusedException(reason: $reason, items: $items, license: $license)';
}
}
@ -89,9 +98,11 @@ class _PublishRefusedExceptionImpl extends PublishRefusedException {
_PublishRefusedExceptionImpl({
required _i5rbkflk.PublishRefusal reason,
List<_isc.UuidValue>? items,
String? license,
}) : super._(
reason: reason,
items: items,
license: license,
);
/// Returns a shallow copy of this [PublishRefusedException]
@ -101,12 +112,14 @@ class _PublishRefusedExceptionImpl extends PublishRefusedException {
PublishRefusedException copyWith({
_i5rbkflk.PublishRefusal? reason,
Object? items = _Undefined,
Object? license = _Undefined,
}) {
return PublishRefusedException(
reason: reason ?? this.reason,
items: items is List<_isc.UuidValue>?
? items
: this.items?.map((e0) => e0).toList(),
license: license is String? ? license : this.license,
);
}
}

View file

@ -1441,5 +1441,13 @@
"copyUpdated": "Auf v{version} aktualisiert.",
"copyUpdatedToSettle": "{count, plural, =1{Bei einem hast du dort auch etwas geändert: öffne es, um zu wählen.} other{Bei {count} hast du dort auch etwas geändert: öffne sie, um zu wählen.}}",
"copyUpdateFailed": "Deine Instanz war nicht erreichbar. Versuche es noch einmal.",
"cardBrowserStatusWithdrawn": "Im Original zurückgezogen"
"cardBrowserStatusWithdrawn": "Im Original zurückgezogen",
"settingsPrivacy": "Privatsphäre",
"settingsCountLearning": "Mein Lernen in Entdecken mitzählen",
"settingsCountLearningSubtitle": "Entdecken zeigt, wie viele auf dieser Instanz ein veröffentlichtes Lernset lernen: Personen mit einer Identität, die das gewählt und es in den letzten 30 Tagen an 3 oder mehr Tagen gelernt haben, gerundet, ab 5. Nichts über dich wird gezeigt, und nichts verlässt die Instanz.",
"exploreLearners": "{count}+ lernen das hier",
"publishLearnersFew": "Weniger als 5 lernen es hier, von denen, die mitgezählt werden möchten.",
"publishForkForbidden": "Sein Original verbietet, Bearbeitungen weiterzugeben: nur wer es verfasst hat, veröffentlicht es.",
"publishLicenseMustStay": "Sein Original verlangt, dass Bearbeitungen unter {license} bleiben: wähle sie unter Bearbeiten, für jedes Lernset darin.",
"exploreBasedOn": "nach {location}"
}

View file

@ -1686,5 +1686,16 @@
"copyUpdatedToSettle": "{count, plural, =1{One has a change of yours there too: open it to choose.} other{{count} have a change of yours there too: open them to choose.}}",
"@copyUpdatedToSettle": {"placeholders": {"count": {"type": "int"}}},
"copyUpdateFailed": "Couldn't reach your instance. Try again.",
"cardBrowserStatusWithdrawn": "Withdrawn by the author"
"cardBrowserStatusWithdrawn": "Withdrawn by the author",
"settingsPrivacy": "Privacy",
"settingsCountLearning": "Count my learning in Explore",
"settingsCountLearningSubtitle": "Explore shows how many people on this instance learn a published studyset: those with an identity who chose this and studied it on 3 or more days in the last 30, rounded, from 5 on. Nothing about you is shown, and nothing leaves the instance.",
"exploreLearners": "{count}+ learn this here",
"@exploreLearners": {"placeholders": {"count": {"type": "int"}}},
"publishLearnersFew": "Fewer than 5 learn it here, of those who chose to be counted.",
"publishForkForbidden": "Its original forbids passing on adaptations: only its author publishes it.",
"publishLicenseMustStay": "Its original asks adaptations to stay under {license}: choose it under Edit, for every studyset in it.",
"@publishLicenseMustStay": {"placeholders": {"license": {"type": "String"}}},
"exploreBasedOn": "based on {location}",
"@exploreBasedOn": {"placeholders": {"location": {"type": "String"}}}
}

View file

@ -8558,6 +8558,54 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Withdrawn by the author'**
String get cardBrowserStatusWithdrawn;
/// No description provided for @settingsPrivacy.
///
/// In en, this message translates to:
/// **'Privacy'**
String get settingsPrivacy;
/// No description provided for @settingsCountLearning.
///
/// In en, this message translates to:
/// **'Count my learning in Explore'**
String get settingsCountLearning;
/// No description provided for @settingsCountLearningSubtitle.
///
/// In en, this message translates to:
/// **'Explore shows how many people on this instance learn a published studyset: those with an identity who chose this and studied it on 3 or more days in the last 30, rounded, from 5 on. Nothing about you is shown, and nothing leaves the instance.'**
String get settingsCountLearningSubtitle;
/// No description provided for @exploreLearners.
///
/// In en, this message translates to:
/// **'{count}+ learn this here'**
String exploreLearners(int count);
/// No description provided for @publishLearnersFew.
///
/// In en, this message translates to:
/// **'Fewer than 5 learn it here, of those who chose to be counted.'**
String get publishLearnersFew;
/// No description provided for @publishForkForbidden.
///
/// In en, this message translates to:
/// **'Its original forbids passing on adaptations: only its author publishes it.'**
String get publishForkForbidden;
/// No description provided for @publishLicenseMustStay.
///
/// In en, this message translates to:
/// **'Its original asks adaptations to stay under {license}: choose it under Edit, for every studyset in it.'**
String publishLicenseMustStay(String license);
/// No description provided for @exploreBasedOn.
///
/// In en, this message translates to:
/// **'based on {location}'**
String exploreBasedOn(String location);
}
class _AppLocalizationsDelegate

View file

@ -5366,4 +5366,37 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get cardBrowserStatusWithdrawn => 'Im Original zurückgezogen';
@override
String get settingsPrivacy => 'Privatsphäre';
@override
String get settingsCountLearning => 'Mein Lernen in Entdecken mitzählen';
@override
String get settingsCountLearningSubtitle =>
'Entdecken zeigt, wie viele auf dieser Instanz ein veröffentlichtes Lernset lernen: Personen mit einer Identität, die das gewählt und es in den letzten 30 Tagen an 3 oder mehr Tagen gelernt haben, gerundet, ab 5. Nichts über dich wird gezeigt, und nichts verlässt die Instanz.';
@override
String exploreLearners(int count) {
return '$count+ lernen das hier';
}
@override
String get publishLearnersFew =>
'Weniger als 5 lernen es hier, von denen, die mitgezählt werden möchten.';
@override
String get publishForkForbidden =>
'Sein Original verbietet, Bearbeitungen weiterzugeben: nur wer es verfasst hat, veröffentlicht es.';
@override
String publishLicenseMustStay(String license) {
return 'Sein Original verlangt, dass Bearbeitungen unter $license bleiben: wähle sie unter Bearbeiten, für jedes Lernset darin.';
}
@override
String exploreBasedOn(String location) {
return 'nach $location';
}
}

View file

@ -5318,6 +5318,39 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cardBrowserStatusWithdrawn => 'Withdrawn by the author';
@override
String get settingsPrivacy => 'Privacy';
@override
String get settingsCountLearning => 'Count my learning in Explore';
@override
String get settingsCountLearningSubtitle =>
'Explore shows how many people on this instance learn a published studyset: those with an identity who chose this and studied it on 3 or more days in the last 30, rounded, from 5 on. Nothing about you is shown, and nothing leaves the instance.';
@override
String exploreLearners(int count) {
return '$count+ learn this here';
}
@override
String get publishLearnersFew =>
'Fewer than 5 learn it here, of those who chose to be counted.';
@override
String get publishForkForbidden =>
'Its original forbids passing on adaptations: only its author publishes it.';
@override
String publishLicenseMustStay(String license) {
return 'Its original asks adaptations to stay under $license: choose it under Edit, for every studyset in it.';
}
@override
String exploreBasedOn(String location) {
return 'based on $location';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -83,6 +83,7 @@ class ServerpodExploreGateway(final protocol.Client _client)
description: _description(entry.description),
frontLanguage: entry.frontLanguage,
backLanguage: entry.backLanguage,
basedOn: entry.basedOn,
license: entry.license,
licenseHolder: entry.licenseHolder,
licenseYear: entry.licenseYear,
@ -91,6 +92,7 @@ class ServerpodExploreGateway(final protocol.Client _client)
noteCount: entry.noteCount,
paths: entry.pathCount,
pdfs: entry.pdfCount,
learners: entry.learners,
);
/// A description as the instance keeps it, its rich text's JSON; empty

View file

@ -30,6 +30,9 @@ final class const ExploreStudyset({
final String? frontLanguage,
final String? backLanguage,
/// Where the published studyset it is a copy of is, for a copy.
final String? basedOn,
/// Its licence and credits.
required final String license,
final String? licenseHolder,
@ -41,6 +44,10 @@ final class const ExploreStudyset({
final int noteCount = 0,
final int paths = 0,
final int pdfs = 0,
/// How many people learn it here, rounded (5, 10, 20, 50, …); null for
/// fewer than 5 (ADR 0005: shown, never ranked).
final int? learners,
}) with Equatable {
/// Its author as they are shown: their display name, else their handle.
String get author => displayName.isEmpty ? '@$handle' : displayName;
@ -60,6 +67,7 @@ final class const ExploreStudyset({
description,
frontLanguage,
backLanguage,
basedOn,
license,
licenseHolder,
licenseYear,
@ -68,6 +76,7 @@ final class const ExploreStudyset({
noteCount,
paths,
pdfs,
learners,
];
}

View file

@ -421,6 +421,8 @@ class const _ExploreCard({
Text(studyset.title, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(l10n.exploreBy(authorLine(studyset)), style: muted),
if (studyset.basedOn case final original?)
Text(l10n.exploreBasedOn(original), style: muted),
if (description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
@ -439,6 +441,8 @@ class const _ExploreCard({
style: muted,
),
ExploreCounts(studyset: studyset),
if (studyset.learners case final learners?)
Text(l10n.exploreLearners(learners), style: muted),
const SizedBox(height: 12),
Row(
children: [

View file

@ -52,6 +52,8 @@ class const _Summary({required final ExploreStudyset studyset})
if (studyset.secondaryTitle case final secondary?)
Text(secondary, style: theme.textTheme.titleSmall),
Text(l10n.exploreBy(authorLine(studyset)), style: muted),
if (studyset.basedOn case final original?)
Text(l10n.exploreBasedOn(original), style: muted),
const SizedBox(height: 12),
if (studyset.description.isBlank)
Text(
@ -72,6 +74,8 @@ class const _Summary({required final ExploreStudyset studyset})
style: muted,
),
ExploreCounts(studyset: studyset),
if (studyset.learners case final learners?)
Text(l10n.exploreLearners(learners), style: muted),
const SizedBox(height: 12),
Text(
l10n.exploreLatestRelease(

View file

@ -114,6 +114,7 @@ class PublishBloc(
ready.next(
problem: refused.problem,
items: refused.items,
license: refused.license,
unsettled: refused.problem == PublishProblem.conflicts
? await _unsettled(refused.items.toSet(), event.subtree)
: const [],
@ -240,15 +241,17 @@ final class const PublishStateReady({
final bool publishing = false,
final PublishProblem? problem,
final List<String> items = const [],
final String? license,
final List<UnsettledItem> unsettled = const [],
final PublishOutcome? outcome,
}) extends PublishState {
/// This state with [publishing], [problem], [items], [unsettled] and
/// [outcome] as given.
/// This state with [publishing], [problem], [items], [license],
/// [unsettled] and [outcome] as given.
PublishStateReady next({
bool publishing = false,
PublishProblem? problem,
List<String> items = const [],
String? license,
List<UnsettledItem> unsettled = const [],
PublishOutcome? outcome,
}) => PublishStateReady(
@ -257,6 +260,7 @@ final class const PublishStateReady({
publishing: publishing,
problem: problem,
items: items,
license: license,
unsettled: unsettled,
outcome: outcome,
);
@ -268,6 +272,7 @@ final class const PublishStateReady({
publishing,
problem,
items,
license,
unsettled,
outcome,
];

View file

@ -37,6 +37,7 @@ class ServerpodPublishGateway(final protocol.Client _client)
for (final item in error.items ?? const <protocol.UuidValue>[])
item.uuid,
],
license: error.license,
);
}
}
@ -67,5 +68,6 @@ class ServerpodPublishGateway(final protocol.Client _client)
),
],
withdrawn: publication.withdrawn,
learners: publication.learners,
);
}

View file

@ -19,6 +19,10 @@ final class const Publication({
/// Whether its author took it off Explore: nobody can add it, and copies
/// get no updates, until it is published again.
final bool withdrawn = false,
/// How many people learn it here, rounded as Explore shows it; null for
/// fewer than 5.
final int? learners,
}) with Equatable {
/// The newest release; null before the first.
Release? get latest => releases.firstOrNull;
@ -27,7 +31,14 @@ final class const Publication({
int get nextVersion => (latest?.version ?? 0) + 1;
@override
List<Object?> get props => [publicId, slug, location, releases, withdrawn];
List<Object?> get props => [
publicId,
slug,
location,
releases,
withdrawn,
learners,
];
}
/// One release of a published studyset: what it holds, never changed.
@ -99,13 +110,23 @@ enum PublishProblem() {
/// The studyset is being published already.
busy,
/// A copy of a published studyset whose licence forbids passing on
/// adaptations: only its author publishes it.
forkForbidden,
/// A copy of a published studyset under a licence that asks adaptations
/// to stay under it ([PublishRefused.license]).
licenseMustStay,
}
/// The instance refused to publish, for [problem]; [items] names the
/// studysets, or the cards, notes and steps, it is about.
/// studysets, or the cards, notes and steps, it is about, and [license] the
/// licence a copy must keep.
final class PublishRefused(
final PublishProblem problem, {
final List<String> items = const [],
final String? license,
}) implements Exception {
@override
String toString() => 'PublishRefused($problem, $items)';

View file

@ -188,6 +188,7 @@ class const _Form({
final problem? when about.contains(problem) => _problemText(
l10n,
problem,
license: state.license,
),
_ => null,
};
@ -198,6 +199,14 @@ class const _Form({
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Status(publication: publication),
if (publication != null && !publication.withdrawn)
Text(
switch (publication.learners) {
final learners? => l10n.exploreLearners(learners),
null => l10n.publishLearnersFew,
},
style: muted,
),
const SizedBox(height: 8),
Text(l10n.publishIntro, style: muted),
const SizedBox(height: 16),
@ -269,6 +278,8 @@ class const _Form({
PublishProblem.nothingChanged,
PublishProblem.busy,
PublishProblem.noProfile,
PublishProblem.forkForbidden,
PublishProblem.licenseMustStay,
})
case final text?) ...[
const SizedBox(height: 8),
@ -507,8 +518,11 @@ String _date(BuildContext context, DateTime at) => DateFormat.yMMMd(
AppLocalizations.of(context).localeName,
).format(at.toLocal());
String _problemText(AppLocalizations l10n, PublishProblem problem) =>
switch (problem) {
String _problemText(
AppLocalizations l10n,
PublishProblem problem, {
String? license,
}) => switch (problem) {
PublishProblem.noProfile => l10n.publishNoProfile,
PublishProblem.licenseNotOpen => l10n.publishLicenceClosed,
PublishProblem.conflicts => l10n.publishConflictsElsewhere,
@ -519,4 +533,8 @@ String _problemText(AppLocalizations l10n, PublishProblem problem) =>
PublishProblem.slugTaken => l10n.publishSlugTaken,
PublishProblem.slugFixed => l10n.publishNameFixed,
PublishProblem.busy => l10n.publishBusy,
};
PublishProblem.forkForbidden => l10n.publishForkForbidden,
PublishProblem.licenseMustStay => l10n.publishLicenseMustStay(
license ?? '',
),
};

View file

@ -0,0 +1,31 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
/// Whether the person's learning counts in Explore (`countLearning` of
/// their settings), as their settings hold it now; null until read.
class PrivacySettingsBloc(final SettingsRepository _settings)
extends Bloc<PrivacySettingsEvent, bool?> {
this : super(null) {
on<PrivacySettingsEventStarted>(
(event, emit) => emit.forEach(
_settings.watch(),
onData: (settings) => settings.countLearning,
),
);
on<PrivacySettingsEventCountingChosen>(
(event, emit) => _settings.update(
(settings) => settings.copyWith(countLearning: event.counted),
),
);
}
}
/// What happens to the person's privacy settings.
sealed class const PrivacySettingsEvent();
/// The settings show: follow them.
final class const PrivacySettingsEventStarted() extends PrivacySettingsEvent;
/// The person chose whether their learning counts in Explore.
final class const PrivacySettingsEventCountingChosen(final bool counted)
extends PrivacySettingsEvent;

Some files were not shown because too many files have changed in this diff Show more