feat(publish): releases in git (public tier, stage 2)

A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.

A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.

In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.

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-09 22:01:00 +02:00
commit f635b0816a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
65 changed files with 20287 additions and 77 deletions

View file

@ -6,6 +6,8 @@ halkyon_learn_server/config/passwords.yaml
# Dictionary files: tens of megabytes, mounted on an instance, never built in # Dictionary files: tens of megabytes, mounted on an instance, never built in
halkyon_learn_server/dictionaries/ halkyon_learn_server/dictionaries/
deploy/dictionaries/ deploy/dictionaries/
# Published studysets' repositories of a development server
halkyon_learn_server/repositories/
halkyon_learn_server/.serverpod/ halkyon_learn_server/.serverpod/
deploy/.env deploy/.env
**/*.log **/*.log

View file

@ -121,7 +121,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **CI** (`.forgejo/workflows/ci.yaml`) runs `tool/check.sh` on every push, on the Forgejo runner on sahaquiel (`deploy/runner/`), except for a commit that passed before: the job marks passed commits in the runner's home (`checked/`, kept 90 days), so a branch's commit fast-forwarded into `main` goes straight to its deploy. 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. So never run `dart format` over the server's `lib/` or `test/` as a whole: it reformats the generated code (`lib/src/generated/`, `serverpod_test_tools.dart`); format the files you wrote. 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/`), except for a commit that passed before: the job marks passed commits in the runner's home (`checked/`, kept 90 days), so a branch's commit fast-forwarded into `main` goes straight to its deploy. 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. So never run `dart format` over the server's `lib/` or `test/` as a whole: it reformats the generated code (`lib/src/generated/`, `serverpod_test_tools.dart`); format the files you wrote. 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 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. - **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.
- **Backups** (`deploy/backups.md`): every hour, `deploy/backup.sh` (a systemd timer, as `deploy`) dumps the database, which holds people's files too, and restic stores it in a Hetzner Storage Box (24 hourly, 7 daily, 4 weekly, 3 monthly); the box's own snapshots protect it from a taken-over server. A restore takes the instance back up to an hour, and people who started since lose what their device held: their app no longer finds its session, starts a new anonymous account and deletes the old folder. So a change that makes devices lose their session, or keeps deleted people's data longer, changes what a restore costs; say so in `deploy/backups.md`. - **Backups** (`deploy/backups.md`): every hour, `deploy/backup.sh` (a systemd timer, as `deploy`) dumps the database, which holds people's files too, then bundles each published studyset's repository (`git bundle`, from the server's `repositories` volume), and restic stores both in a Hetzner Storage Box (24 hourly, 7 daily, 4 weekly, 3 monthly); the box's own snapshots protect it from a taken-over server. A restore takes the instance back up to an hour, and people who started since lose what their device held: their app no longer finds its session, starts a new anonymous account and deletes the old folder. So a change that makes devices lose their session, or keeps deleted people's data longer, changes what a restore costs; say so in `deploy/backups.md`.
- **Releases** are the project's, not an instance's: the release, the Snap Store listing and the website point to learn.halkyon.app; the builds connect to learn.halkyon.eu, the project's instance, until the app lets people choose one. They come from a developer's laptop, never from CI: `tool/release.sh` on `main` as pushed and green in CI. Versions follow semantic versioning, worked out from the commits since the last release (`feat` raises the minor number, `fix`/`perf` the patch number, breaking ones the major from 1.0 on; other types release nothing), so **commit messages keep their type prefix**. It writes the version and a raised build number into the app's pubspec in a signed release commit (`chore(release): vX.Y.Z`) with a signed tag (a key in `deploy/allowed-signers`), builds one universal APK from a checkout of the tag, signed with the app's key, and the snap (`snap/snapcraft.yaml`, the committed HEAD, built in LXD), both with `SERVER_URL=https://learn.halkyon.eu/`; signs `SHA256SUMS` with GPG; pushes only once both builds passed their checks; and publishes on code.halkyon.app through Forgejo's API and in the F-Droid repository. The Snap Store upload is off until `snap_store_channel` in the script names a channel. A run that stopped after its release commit and tag carries on with them. The key's password and the Forgejo token are asked for at each run and never stored. The app's key is a PKCS #12 keystore outside the repository (also in the maintainer's password manager); there is no Google Play, so it is the app's identity: lost, installed apps can't be updated. Its certificate's SHA-256 is in `tool/release/android-certificate.sha256`, and the script releases no APK signed otherwise; other release builds fall back to the debug key (`android/app/build.gradle.kts`), so `flutter run --release` works anywhere. The APK also goes into the project's own F-Droid repository, https://halkyon.app/fdroid/repo, through `tool/fdroid_repo.sh` (fdroidserver v2.4.5): its settings and the app's listing are in `tool/release/fdroid/` (the anti-features TetheredNet and NonFreeNet declared with their reasons and F-Droid's own icons); its index is signed with a key of its own (a PKCS #12 keystore beside the app's, password asked for, certificate pinned in `tool/release/fdroid-index-certificate.sha256`, since a new key makes the F-Droid app refuse the repository); and it is uploaded to ramiel as the user `fdroid`, whose key can only write into the folder Caddy serves (rrsync), the APKs first and the indexes last. - **Releases** are the project's, not an instance's: the release, the Snap Store listing and the website point to learn.halkyon.app; the builds connect to learn.halkyon.eu, the project's instance, until the app lets people choose one. They come from a developer's laptop, never from CI: `tool/release.sh` on `main` as pushed and green in CI. Versions follow semantic versioning, worked out from the commits since the last release (`feat` raises the minor number, `fix`/`perf` the patch number, breaking ones the major from 1.0 on; other types release nothing), so **commit messages keep their type prefix**. It writes the version and a raised build number into the app's pubspec in a signed release commit (`chore(release): vX.Y.Z`) with a signed tag (a key in `deploy/allowed-signers`), builds one universal APK from a checkout of the tag, signed with the app's key, and the snap (`snap/snapcraft.yaml`, the committed HEAD, built in LXD), both with `SERVER_URL=https://learn.halkyon.eu/`; signs `SHA256SUMS` with GPG; pushes only once both builds passed their checks; and publishes on code.halkyon.app through Forgejo's API and in the F-Droid repository. The Snap Store upload is off until `snap_store_channel` in the script names a channel. A run that stopped after its release commit and tag carries on with them. The key's password and the Forgejo token are asked for at each run and never stored. The app's key is a PKCS #12 keystore outside the repository (also in the maintainer's password manager); there is no Google Play, so it is the app's identity: lost, installed apps can't be updated. Its certificate's SHA-256 is in `tool/release/android-certificate.sha256`, and the script releases no APK signed otherwise; other release builds fall back to the debug key (`android/app/build.gradle.kts`), so `flutter run --release` works anywhere. The APK also goes into the project's own F-Droid repository, https://halkyon.app/fdroid/repo, through `tool/fdroid_repo.sh` (fdroidserver v2.4.5): its settings and the app's listing are in `tool/release/fdroid/` (the anti-features TetheredNet and NonFreeNet declared with their reasons and F-Droid's own icons); its index is signed with a key of its own (a PKCS #12 keystore beside the app's, password asked for, certificate pinned in `tool/release/fdroid-index-certificate.sha256`, since a new key makes the F-Droid app refuse the repository); and it is uploaded to ramiel as the user `fdroid`, whose key can only write into the folder Caddy serves (rrsync), the APKs first and the indexes last.
## Rules the code relies on ## Rules the code relies on
@ -157,6 +157,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on. - **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on.
- **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. - **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. - **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.
- **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. - **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. - **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. - **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

@ -4,8 +4,9 @@
# how it was set up and how to restore. # how it was set up and how to restore.
# #
# Everything people keep is in the database, their files too (Serverpod's database # Everything people keep is in the database, their files too (Serverpod's database
# cloud storage), so one dump covers the instance; the secrets in deploy/.env are kept # cloud storage), but for the releases of published studysets, which are git
# in Proton Pass instead. restic stores the dump encrypted in a Hetzner Storage Box # repositories in the server's volume: a dump and a bundle of each repository cover
# the instance; the secrets in deploy/.env are kept in Proton Pass instead. restic stores the dump encrypted in a Hetzner Storage Box
# and keeps 24 hourly, 7 daily, 4 weekly and 3 monthly snapshots (decided 2026-10-03). # and keeps 24 hourly, 7 daily, 4 weekly and 3 monthly snapshots (decided 2026-10-03).
# Hourly, because a restore loses what came after the backup, and for someone who # Hourly, because a restore loses what came after the backup, and for someone who
# started since, that is everything: their app no longer finds its session and starts # started since, that is everything: their app no longer finds its session and starts
@ -44,7 +45,29 @@ dump="$HOME/backups/latest.dump"
) )
mv "$dump.partial" "$dump" mv "$dump.partial" "$dump"
restic backup --quiet "$dump" # Each repository as a git bundle, after the dump: a release the dump lacks is never
# numbered again (the server numbers past the highest tag), while one the bundles
# lacked would be lost. A bundle is whole whenever it is made: git reads the refs, then
# packs what they reach. A repository being created this moment has nothing to bundle
# yet, and comes next hour.
releases="$HOME/backups/repositories.tar"
(
umask 077
docker compose exec -T server sh -c '
out=$(mktemp -d)
for repository in /app/repositories/*.git; do
[ -d "$repository" ] || continue
git -C "$repository" bundle create --quiet \
"$out/$(basename "$repository" .git).bundle" --all ||
echo "Not bundled: $repository" >&2
done
tar -C "$out" -cf - .
rm -rf "$out"
' >"$releases.partial"
)
mv "$releases.partial" "$releases"
restic backup --quiet "$dump" "$releases"
restic forget --quiet --keep-hourly 24 --keep-daily 7 --keep-weekly 4 --keep-monthly 3 restic forget --quiet --keep-hourly 24 --keep-daily 7 --keep-weekly 4 --keep-monthly 3
# Once a day, in the run at 03:00: free the space of forgotten snapshots, and read the # Once a day, in the run at 03:00: free the space of forgotten snapshots, and read the

View file

@ -1,22 +1,26 @@
# Backups # Backups
learn.halkyon.eu backs up its database every hour, encrypted, to a Hetzner Storage Box: learn.halkyon.eu backs up its database every hour, encrypted, to a Hetzner Storage Box:
`backup.sh`, started by the systemd timer in `backup/`. The database is all there is to `backup.sh`, started by the systemd timer in `backup/`. The database holds nearly all
back up: people's files are in it too (Serverpod's database cloud storage). The other there is to back up, people's files too (Serverpod's database cloud storage); the
state of the instance is `deploy/.env`, whose secrets are kept in a password manager, releases of published studysets are git repositories in the server's `repositories`
and Caddy's certificates, which it fetches again. volume (ADR 0005, rule 3), backed up beside it as a git bundle each. The other state of
the instance is `deploy/.env`, whose secrets are kept in a password manager, Caddy's
certificates, which it fetches again, and the dictionary files, which it downloads
again.
| | | | | |
|---|---| |---|---|
| When | every hour, on the hour; a run missed while the server was off happens when it is back | | When | every hour, on the hour; a run missed while the server was off happens when it is back |
| What | `pg_dump` of the whole database, stored by [restic](https://restic.net) (encrypted, deduplicated, compressed) | | What | `pg_dump` of the whole database, then a `git bundle` of each published studyset's repository in one tar file, stored by [restic](https://restic.net) (encrypted, deduplicated, compressed) |
| Kept | 24 hourly, 7 daily, 4 weekly and 3 monthly snapshots; a deleted account is gone from every backup within about 3 months | | Kept | 24 hourly, 7 daily, 4 weekly and 3 monthly snapshots; a deleted account is gone from every backup within about 3 months |
| Where | a sub-account of the Storage Box (SFTP), used for nothing else | | Where | a sub-account of the Storage Box (SFTP), used for nothing else |
| Protected by | the box's automatic snapshots, daily, which only Hetzner's Console can remove: a taken-over server can delete what restic wrote, but not those | | Protected by | the box's automatic snapshots, daily, which only Hetzner's Console can remove: a taken-over server can delete what restic wrote, but not those |
| Checked | every night at 03:00: forgotten snapshots pruned, the repository's structure read; on Sundays also a random tenth of the data | | Checked | every night at 03:00: forgotten snapshots pruned, the repository's structure read; on Sundays also a random tenth of the data |
| Failures | in the journal only: `systemctl status halkyon-backup`, `journalctl -u halkyon-backup` | | Failures | in the journal only: `systemctl status halkyon-backup`, `journalctl -u halkyon-backup` |
The newest dump also stays on the server, as `~deploy/backups/latest.dump`. The newest dump also stays on the server, as `~deploy/backups/latest.dump`, and the
newest bundles as `~deploy/backups/repositories.tar`.
**Without the restic password, the backups can't be read by anyone, you included.** Keep **Without the restic password, the backups can't be read by anyone, you included.** Keep
it, with the box's login, somewhere other than the server. it, with the box's login, somewhere other than the server.
@ -34,6 +38,10 @@ then sync with a server that has forgotten their last hour:
signed in after the backup, though a GitHub account can be signed into again. signed in after the backup, though a GitHub account can be signed into again.
- **An account deleted after the backup** comes back. Delete it again if you know which - **An account deleted after the backup** comes back. Delete it again if you know which
one it was. one it was.
- **A release published after the backup** is gone from the instance; learners who took
it keep their copies. Its version number is given again by the next release only if
the bundles lack it too: the server numbers releases past both the database's and the
repository's highest.
## Setting it up ## Setting it up
@ -154,7 +162,26 @@ the way back from a restore that went wrong.
pg_restore -U halkyon_learn -d postgres --clean --create <~/backups/restore.dump pg_restore -U halkyon_learn -d postgres --clean --create <~/backups/restore.dump
``` ```
4. **Start the server.** It applies the migrations that came after the dump: 4. **Replace the repositories** of published studysets with the bundles of the same
snapshot (or `~/backups/repositories.tar`, beside the newest dump), in a container of
the server's image that does only this:
```sh
restic dump <snapshot> /home/deploy/backups/repositories.tar >~/backups/restore-repositories.tar
docker compose run --rm --no-deps -T --entrypoint sh server -ec '
mkdir /tmp/bundles && tar -C /tmp/bundles -xf -
rm -rf /app/repositories/*.git
for bundle in /tmp/bundles/*.bundle; do
[ -f "$bundle" ] || continue
repository=/app/repositories/$(basename "$bundle" .bundle).git
git init --bare --quiet --initial-branch=main "$repository"
git -C "$repository" config gc.auto 0
git -C "$repository" fetch --quiet "$bundle" "refs/*:refs/*"
done
' <~/backups/restore-repositories.tar
```
5. **Start the server.** It applies the migrations that came after the dump:
```sh ```sh
docker compose up -d docker compose up -d

View file

@ -58,12 +58,17 @@ services:
FILE_LIMIT_ANONYMOUS_MB: ${FILE_LIMIT_ANONYMOUS_MB:-} FILE_LIMIT_ANONYMOUS_MB: ${FILE_LIMIT_ANONYMOUS_MB:-}
FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-} FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-}
FILE_LIMIT_INSTANCE_MB: ${FILE_LIMIT_INSTANCE_MB:-} FILE_LIMIT_INSTANCE_MB: ${FILE_LIMIT_INSTANCE_MB:-}
# The git repositories of published studysets (ADR 0005, rule 3).
REPOSITORIES_DIR: /app/repositories
# The dictionary files it loads at start and rereads hourly, those that changed # The dictionary files it loads at start and rereads hourly, those that changed
# (see the server's README): those the dictionaries service keeps current, read # (see the server's README): those the dictionaries service keeps current, read
# only. To supply files yourself instead, remove that service and mount # only. To supply files yourself instead, remove that service and mount
# ./dictionaries here (`- ./dictionaries:/app/dictionaries:ro`). # ./dictionaries here (`- ./dictionaries:/app/dictionaries:ro`).
volumes: volumes:
- dictionaries:/app/dictionaries:ro - dictionaries:/app/dictionaries:ro
# Every release of every published studyset: backed up beside the database
# (backup.sh), never deleted with `down -v`.
- repositories:/app/repositories
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -112,3 +117,4 @@ volumes:
caddy_config: caddy_config:
dictionaries: dictionaries:
postgres_data: postgres_data:
repositories:

View file

@ -24,6 +24,8 @@ import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart'
as _i26u8vl4; as _i26u8vl4;
import 'package:halkyon_learn_client/src/protocol/profile/profile.dart' import 'package:halkyon_learn_client/src/protocol/profile/profile.dart'
as _ihcmr71y; as _ihcmr71y;
import 'package:halkyon_learn_client/src/protocol/publish/publication.dart'
as _ij0cqo11;
import 'package:http/http.dart' as _i85jenna; import 'package:http/http.dart' as _i85jenna;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart' import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc; as _iacc;
@ -433,6 +435,52 @@ class EndpointProfiles extends _isc.EndpointRef {
); );
} }
/// Publishing your studysets (ADR 0005, rule 3): each release a commit and
/// a tag in a git repository of the studyset's own, which the server builds
/// from your synced rows. Only for people with an identity and a public
/// profile: a release names its author.
///
/// The person always comes from the session, never from the client: a
/// studyset that isn't the caller's can't be told from one that doesn't
/// exist.
/// {@category Endpoint}
class EndpointPublish extends _isc.EndpointRef {
EndpointPublish(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'publish';
/// Your studyset [studysetId] as published, with its releases, the newest
/// first; null while it never was.
_ida.Future<_ij0cqo11.Publication?> publication(_isc.UuidValue studysetId) =>
caller.callServerEndpoint<_ij0cqo11.Publication?>(
'publish',
'publication',
{'studysetId': studysetId},
);
/// Publishes your studyset [studysetId] with everything inside it as its
/// next release, [notes] saying what changed; at the first, under the
/// name [slug] in its location, which stays its name from then on.
///
/// Throws [PublishRefusedException] when it can't be published (see
/// [PublishRefusal]), and [NotFoundException] for a studyset that isn't
/// yours.
_ida.Future<_ij0cqo11.Publication> publish(
_isc.UuidValue studysetId,
String slug,
String notes,
) => caller.callServerEndpoint<_ij0cqo11.Publication>(
'publish',
'publish',
{
'studysetId': studysetId,
'slug': slug,
'notes': notes,
},
);
}
class Modules { class Modules {
Modules(Client client) { Modules(Client client) {
serverpod_auth_idp = _iaic.Caller(client); serverpod_auth_idp = _iaic.Caller(client);
@ -484,6 +532,7 @@ class Client extends _isc.ServerpodClientShared {
instance = EndpointInstance(this); instance = EndpointInstance(this);
profile = EndpointProfile(this); profile = EndpointProfile(this);
profiles = EndpointProfiles(this); profiles = EndpointProfiles(this);
publish = EndpointPublish(this);
modules = Modules(this); modules = Modules(this);
} }
@ -507,6 +556,8 @@ class Client extends _isc.ServerpodClientShared {
late final EndpointProfiles profiles; late final EndpointProfiles profiles;
late final EndpointPublish publish;
late final Modules modules; late final Modules modules;
@override @override
@ -521,6 +572,7 @@ class Client extends _isc.ServerpodClientShared {
'instance': instance, 'instance': instance,
'profile': profile, 'profile': profile,
'profiles': profiles, 'profiles': profiles,
'publish': publish,
}; };
@override @override

View file

@ -51,6 +51,10 @@ import 'pdf_source/pdf_source.dart' as _iz838ro9;
import 'profile/profile.dart' as _ia5j8fch; import 'profile/profile.dart' as _ia5j8fch;
import 'profile/profile_refusal.dart' as _i6j6efd1; import 'profile/profile_refusal.dart' as _i6j6efd1;
import 'profile/profile_refused_exception.dart' as _idvc5e7p; import 'profile/profile_refused_exception.dart' as _idvc5e7p;
import 'publish/publication.dart' as _i9o1kyhh;
import 'publish/publish_refusal.dart' as _iofgvwaa;
import 'publish/publish_refused_exception.dart' as _itdn8966;
import 'publish/release_info.dart' as _i7kd0wkp;
import 'review/review_log.dart' as _irzjbdqz; import 'review/review_log.dart' as _irzjbdqz;
import 'settings/person_settings.dart' as _irnhx5e5; import 'settings/person_settings.dart' as _irnhx5e5;
import 'studyset/studyset.dart' as _ikbclmae; import 'studyset/studyset.dart' as _ikbclmae;
@ -84,6 +88,10 @@ export 'pdf_source/pdf_source.dart';
export 'profile/profile.dart'; export 'profile/profile.dart';
export 'profile/profile_refusal.dart'; export 'profile/profile_refusal.dart';
export 'profile/profile_refused_exception.dart'; export 'profile/profile_refused_exception.dart';
export 'publish/publication.dart';
export 'publish/publish_refusal.dart';
export 'publish/publish_refused_exception.dart';
export 'publish/release_info.dart';
export 'review/review_log.dart'; export 'review/review_log.dart';
export 'settings/person_settings.dart'; export 'settings/person_settings.dart';
export 'studyset/studyset.dart'; export 'studyset/studyset.dart';
@ -2059,6 +2067,18 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _idvc5e7p.ProfileRefusedException) { if (t == _idvc5e7p.ProfileRefusedException) {
return _idvc5e7p.ProfileRefusedException.fromJson(data) as T; return _idvc5e7p.ProfileRefusedException.fromJson(data) as T;
} }
if (t == _i9o1kyhh.Publication) {
return _i9o1kyhh.Publication.fromJson(data) as T;
}
if (t == _iofgvwaa.PublishRefusal) {
return _iofgvwaa.PublishRefusal.fromJson(data) as T;
}
if (t == _itdn8966.PublishRefusedException) {
return _itdn8966.PublishRefusedException.fromJson(data) as T;
}
if (t == _i7kd0wkp.ReleaseInfo) {
return _i7kd0wkp.ReleaseInfo.fromJson(data) as T;
}
if (t == _irzjbdqz.ReviewLog) { if (t == _irzjbdqz.ReviewLog) {
return _irzjbdqz.ReviewLog.fromJson(data) as T; return _irzjbdqz.ReviewLog.fromJson(data) as T;
} }
@ -2182,6 +2202,22 @@ class Protocol extends _isd.DatabaseSerializationManager {
: null) : null)
as T; as T;
} }
if (t == _isc.getType<_i9o1kyhh.Publication?>()) {
return (data != null ? _i9o1kyhh.Publication.fromJson(data) : null) as T;
}
if (t == _isc.getType<_iofgvwaa.PublishRefusal?>()) {
return (data != null ? _iofgvwaa.PublishRefusal.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_itdn8966.PublishRefusedException?>()) {
return (data != null
? _itdn8966.PublishRefusedException.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_i7kd0wkp.ReleaseInfo?>()) {
return (data != null ? _i7kd0wkp.ReleaseInfo.fromJson(data) : null) as T;
}
if (t == _isc.getType<_irzjbdqz.ReviewLog?>()) { if (t == _isc.getType<_irzjbdqz.ReviewLog?>()) {
return (data != null ? _irzjbdqz.ReviewLog.fromJson(data) : null) as T; return (data != null ? _irzjbdqz.ReviewLog.fromJson(data) : null) as T;
} }
@ -2218,6 +2254,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
.toList() .toList()
as T; as T;
} }
if (t == List<_i7kd0wkp.ReleaseInfo>) {
return (data as List)
.map((e) => deserialize<_i7kd0wkp.ReleaseInfo>(e))
.toList()
as T;
}
if (t == List<_ieqh4yr9.Device>) { if (t == List<_ieqh4yr9.Device>) {
return (data as List) return (data as List)
.map((e) => deserialize<_ieqh4yr9.Device>(e)) .map((e) => deserialize<_ieqh4yr9.Device>(e))
@ -2285,6 +2327,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
_ia5j8fch.Profile => 'Profile', _ia5j8fch.Profile => 'Profile',
_i6j6efd1.ProfileRefusal => 'ProfileRefusal', _i6j6efd1.ProfileRefusal => 'ProfileRefusal',
_idvc5e7p.ProfileRefusedException => 'ProfileRefusedException', _idvc5e7p.ProfileRefusedException => 'ProfileRefusedException',
_i9o1kyhh.Publication => 'Publication',
_iofgvwaa.PublishRefusal => 'PublishRefusal',
_itdn8966.PublishRefusedException => 'PublishRefusedException',
_i7kd0wkp.ReleaseInfo => 'ReleaseInfo',
_irzjbdqz.ReviewLog => 'ReviewLog', _irzjbdqz.ReviewLog => 'ReviewLog',
_irnhx5e5.PersonSettings => 'PersonSettings', _irnhx5e5.PersonSettings => 'PersonSettings',
_ikbclmae.Studyset => 'Studyset', _ikbclmae.Studyset => 'Studyset',
@ -2361,6 +2407,14 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'ProfileRefusal'; return 'ProfileRefusal';
case _idvc5e7p.ProfileRefusedException(): case _idvc5e7p.ProfileRefusedException():
return 'ProfileRefusedException'; return 'ProfileRefusedException';
case _i9o1kyhh.Publication():
return 'Publication';
case _iofgvwaa.PublishRefusal():
return 'PublishRefusal';
case _itdn8966.PublishRefusedException():
return 'PublishRefusedException';
case _i7kd0wkp.ReleaseInfo():
return 'ReleaseInfo';
case _irzjbdqz.ReviewLog(): case _irzjbdqz.ReviewLog():
return 'ReviewLog'; return 'ReviewLog';
case _irnhx5e5.PersonSettings(): case _irnhx5e5.PersonSettings():
@ -2481,6 +2535,18 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'ProfileRefusedException') { if (dataClassName == 'ProfileRefusedException') {
return deserialize<_idvc5e7p.ProfileRefusedException>(data['data']); return deserialize<_idvc5e7p.ProfileRefusedException>(data['data']);
} }
if (dataClassName == 'Publication') {
return deserialize<_i9o1kyhh.Publication>(data['data']);
}
if (dataClassName == 'PublishRefusal') {
return deserialize<_iofgvwaa.PublishRefusal>(data['data']);
}
if (dataClassName == 'PublishRefusedException') {
return deserialize<_itdn8966.PublishRefusedException>(data['data']);
}
if (dataClassName == 'ReleaseInfo') {
return deserialize<_i7kd0wkp.ReleaseInfo>(data['data']);
}
if (dataClassName == 'ReviewLog') { if (dataClassName == 'ReviewLog') {
return deserialize<_irzjbdqz.ReviewLog>(data['data']); return deserialize<_irzjbdqz.ReviewLog>(data['data']);
} }

View file

@ -0,0 +1,141 @@
/* 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:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import '../publish/release_info.dart' as _ia42v78b;
/// A published studyset as the app shows it to its person: where it is, and
/// its releases, the newest first.
abstract class Publication
implements _isc.SerializableModel, _isc.ProtocolSerialization {
Publication._({
required this.publicId,
required this.studysetId,
required this.slug,
required this.location,
required this.releases,
});
factory Publication({
required _isc.UuidValue publicId,
required _isc.UuidValue studysetId,
required String slug,
required String location,
required List<_ia42v78b.ReleaseInfo> releases,
}) = _PublicationImpl;
factory Publication.fromJson(Map<String, dynamic> jsonSerialization) {
return Publication(
publicId: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['publicId'],
),
studysetId: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['studysetId'],
),
slug: jsonSerialization['slug'] as String,
location: jsonSerialization['location'] as String,
releases: _itx5j3f5.Protocol().deserialize<List<_ia42v78b.ReleaseInfo>>(
jsonSerialization['releases'],
),
);
}
/// Its identity for good.
_isc.UuidValue publicId;
/// The studyset published.
_isc.UuidValue studysetId;
/// Its name in its location.
String slug;
/// Where it is (`halkyon.eu/@raisondetredev/japanese`).
String location;
List<_ia42v78b.ReleaseInfo> releases;
/// Returns a shallow copy of this [Publication]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
Publication copyWith({
_isc.UuidValue? publicId,
_isc.UuidValue? studysetId,
String? slug,
String? location,
List<_ia42v78b.ReleaseInfo>? releases,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'Publication',
'publicId': publicId.toJson(),
'studysetId': studysetId.toJson(),
'slug': slug,
'location': location,
'releases': releases.toJson(valueToJson: (v) => v.toJson()),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'Publication',
'publicId': publicId.toJson(),
'studysetId': studysetId.toJson(),
'slug': slug,
'location': location,
'releases': releases.toJson(valueToJson: (v) => v.toJsonForProtocol()),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _PublicationImpl extends Publication {
_PublicationImpl({
required _isc.UuidValue publicId,
required _isc.UuidValue studysetId,
required String slug,
required String location,
required List<_ia42v78b.ReleaseInfo> releases,
}) : super._(
publicId: publicId,
studysetId: studysetId,
slug: slug,
location: location,
releases: releases,
);
/// Returns a shallow copy of this [Publication]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
Publication copyWith({
_isc.UuidValue? publicId,
_isc.UuidValue? studysetId,
String? slug,
String? location,
List<_ia42v78b.ReleaseInfo>? releases,
}) {
return Publication(
publicId: publicId ?? this.publicId,
studysetId: studysetId ?? this.studysetId,
slug: slug ?? this.slug,
location: location ?? this.location,
releases: releases ?? this.releases.map((e0) => e0.copyWith()).toList(),
);
}
}

View file

@ -0,0 +1,86 @@
/* 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;
/// Why publishing was refused; nothing was published.
enum PublishRefusal implements _isc.SerializableModel {
/// The person has no public profile yet: a release names its author.
noProfile,
/// A studyset's licence doesn't allow passing it on (none, or one not
/// known to be open): mirroring and copying are passing it on. `items`
/// says which studysets.
licenseNotOpen,
/// Cards, notes or steps have a conflict to settle first (`items` says
/// which).
conflicts,
/// Files of the content haven't reached the instance yet: a device still
/// uploads them.
filesMissing,
/// Nothing changed since the last release.
nothingChanged,
/// The release notes are longer than 5,000 characters.
notesTooLong,
/// The name isn't 1 to 50 lower-case letters, digits or hyphens, starting
/// with a letter or digit.
slugInvalid,
/// Another studyset of the person has the name.
slugTaken,
/// The studyset has its name since its first publish: others refer to it.
slugFixed,
/// Publishing that studyset is already under way.
busy;
static PublishRefusal fromJson(String name) {
switch (name) {
case 'noProfile':
return PublishRefusal.noProfile;
case 'licenseNotOpen':
return PublishRefusal.licenseNotOpen;
case 'conflicts':
return PublishRefusal.conflicts;
case 'filesMissing':
return PublishRefusal.filesMissing;
case 'nothingChanged':
return PublishRefusal.nothingChanged;
case 'notesTooLong':
return PublishRefusal.notesTooLong;
case 'slugInvalid':
return PublishRefusal.slugInvalid;
case 'slugTaken':
return PublishRefusal.slugTaken;
case 'slugFixed':
return PublishRefusal.slugFixed;
case 'busy':
return PublishRefusal.busy;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "PublishRefusal"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -0,0 +1,112 @@
/* 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:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import '../publish/publish_refusal.dart' as _i5rbkflk;
/// Publishing was refused; nothing was published.
abstract class PublishRefusedException
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
PublishRefusedException._({
required this.reason,
this.items,
});
factory PublishRefusedException({
required _i5rbkflk.PublishRefusal reason,
List<_isc.UuidValue>? items,
}) = _PublishRefusedExceptionImpl;
factory PublishRefusedException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return PublishRefusedException(
reason: _i5rbkflk.PublishRefusal.fromJson(
(jsonSerialization['reason'] as String),
),
items: jsonSerialization['items'] == null
? null
: _itx5j3f5.Protocol().deserialize<List<_isc.UuidValue>>(
jsonSerialization['items'],
),
);
}
/// Why.
_i5rbkflk.PublishRefusal reason;
/// With `conflicts`, the cards, notes and steps to settle; with
/// `licenseNotOpen`, the studysets whose licence doesn't allow it.
List<_isc.UuidValue>? items;
/// 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,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'PublishRefusedException',
'reason': reason.toJson(),
if (items != null) 'items': items?.toJson(valueToJson: (v) => v.toJson()),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'PublishRefusedException',
'reason': reason.toJson(),
if (items != null) 'items': items?.toJson(valueToJson: (v) => v.toJson()),
};
}
@override
String toString() {
return 'PublishRefusedException(reason: $reason, items: $items)';
}
}
class _Undefined {}
class _PublishRefusedExceptionImpl extends PublishRefusedException {
_PublishRefusedExceptionImpl({
required _i5rbkflk.PublishRefusal reason,
List<_isc.UuidValue>? items,
}) : super._(
reason: reason,
items: items,
);
/// Returns a shallow copy of this [PublishRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
PublishRefusedException copyWith({
_i5rbkflk.PublishRefusal? reason,
Object? items = _Undefined,
}) {
return PublishRefusedException(
reason: reason ?? this.reason,
items: items is List<_isc.UuidValue>?
? items
: this.items?.map((e0) => e0).toList(),
);
}
}

View file

@ -0,0 +1,182 @@
/* 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 release as the app shows it.
abstract class ReleaseInfo
implements _isc.SerializableModel, _isc.ProtocolSerialization {
ReleaseInfo._({
required this.version,
required this.notes,
required this.license,
required this.studysetCount,
required this.cardCount,
required this.noteCount,
required this.pathCount,
required this.pdfCount,
required this.releasedAt,
});
factory ReleaseInfo({
required int version,
required String notes,
required String license,
required int studysetCount,
required int cardCount,
required int noteCount,
required int pathCount,
required int pdfCount,
required DateTime releasedAt,
}) = _ReleaseInfoImpl;
factory ReleaseInfo.fromJson(Map<String, dynamic> jsonSerialization) {
return ReleaseInfo(
version: jsonSerialization['version'] as int,
notes: jsonSerialization['notes'] as String,
license: jsonSerialization['license'] as String,
studysetCount: jsonSerialization['studysetCount'] as int,
cardCount: jsonSerialization['cardCount'] as int,
noteCount: jsonSerialization['noteCount'] as int,
pathCount: jsonSerialization['pathCount'] as int,
pdfCount: jsonSerialization['pdfCount'] as int,
releasedAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['releasedAt'],
),
);
}
/// 1, 2, 3, …
int version;
/// What changed; empty for nothing said.
String notes;
/// The licence it is under.
String license;
int studysetCount;
int cardCount;
int noteCount;
int pathCount;
int pdfCount;
/// When it was released, in UTC.
DateTime releasedAt;
/// Returns a shallow copy of this [ReleaseInfo]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
ReleaseInfo copyWith({
int? version,
String? notes,
String? license,
int? studysetCount,
int? cardCount,
int? noteCount,
int? pathCount,
int? pdfCount,
DateTime? releasedAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'ReleaseInfo',
'version': version,
'notes': notes,
'license': license,
'studysetCount': studysetCount,
'cardCount': cardCount,
'noteCount': noteCount,
'pathCount': pathCount,
'pdfCount': pdfCount,
'releasedAt': releasedAt.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'ReleaseInfo',
'version': version,
'notes': notes,
'license': license,
'studysetCount': studysetCount,
'cardCount': cardCount,
'noteCount': noteCount,
'pathCount': pathCount,
'pdfCount': pdfCount,
'releasedAt': releasedAt.toJson(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _ReleaseInfoImpl extends ReleaseInfo {
_ReleaseInfoImpl({
required int version,
required String notes,
required String license,
required int studysetCount,
required int cardCount,
required int noteCount,
required int pathCount,
required int pdfCount,
required DateTime releasedAt,
}) : super._(
version: version,
notes: notes,
license: license,
studysetCount: studysetCount,
cardCount: cardCount,
noteCount: noteCount,
pathCount: pathCount,
pdfCount: pdfCount,
releasedAt: releasedAt,
);
/// Returns a shallow copy of this [ReleaseInfo]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
ReleaseInfo copyWith({
int? version,
String? notes,
String? license,
int? studysetCount,
int? cardCount,
int? noteCount,
int? pathCount,
int? pdfCount,
DateTime? releasedAt,
}) {
return ReleaseInfo(
version: version ?? this.version,
notes: notes ?? this.notes,
license: license ?? this.license,
studysetCount: studysetCount ?? this.studysetCount,
cardCount: cardCount ?? this.cardCount,
noteCount: noteCount ?? this.noteCount,
pathCount: pathCount ?? this.pathCount,
pdfCount: pdfCount ?? this.pdfCount,
releasedAt: releasedAt ?? this.releasedAt,
);
}
}

View file

@ -1356,5 +1356,45 @@
"cardBrowserResetTitle": "{count, plural, =1{1 Karteikarte neu beginnen?} other{{count} Karteikarten neu beginnen?}}", "cardBrowserResetTitle": "{count, plural, =1{1 Karteikarte neu beginnen?} other{{count} Karteikarten neu beginnen?}}",
"cardBrowserResetMessage": "Sie gelten wieder als neu. Ihre Wiederholungen bleiben in deinem Verlauf und deinen Serien.", "cardBrowserResetMessage": "Sie gelten wieder als neu. Ihre Wiederholungen bleiben in deinem Verlauf und deinen Serien.",
"cardBrowserResetConfirm": "Zurücksetzen", "cardBrowserResetConfirm": "Zurücksetzen",
"cardBrowserReset": "{count, plural, =1{1 Karteikarte beginnt neu} other{{count} Karteikarten beginnen neu}}" "cardBrowserReset": "{count, plural, =1{1 Karteikarte beginnt neu} other{{count} Karteikarten beginnen neu}}",
"studysetShare": "Teilen",
"studysetSharePrivate": "Teilen: privat",
"studysetSharePublished": "Teilen: veröffentlicht",
"publishTitle": "Veröffentlichen",
"publishPrivate": "Privat · nur du siehst es",
"publishPublished": "Veröffentlicht · v{version} · {date}",
"publishIntro": "Eine Veröffentlichung ist dieses Lernset mit allem darin, so wie es jetzt ist. Andere können es finden, damit lernen und es kopieren. Jede Veröffentlichung bleibt, wie sie war; spätere Änderungen kommen in die nächste.",
"publishAnonymous": "Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.",
"publishName": "Name",
"publishNameHint": "japanisch",
"publishLocation": "Zu finden unter {location}",
"publishNameFixed": "Fest, seit du zuerst veröffentlicht hast: andere verweisen darauf.",
"publishLicence": "Lizenz",
"publishLicenceNone": "Keine",
"publishLicenceOpen": "Erlaubt das Veröffentlichen.",
"publishLicenceNoDerivatives": "Erlaubt das Veröffentlichen, wie es ist: Lernende behalten ihre Änderungen für sich.",
"publishLicenceClosed": "Erlaubt keine Weitergabe. Wähle unter Bearbeiten eine andere.",
"publishLicenceNotOpen": "Die Lizenzen dieser Lernsets erlauben keine Weitergabe: {titles}",
"publishNotes": "Was sich geändert hat",
"publishNotesHint": "Neu in dieser Veröffentlichung …",
"publishReleases": "Veröffentlichungen",
"publishRelease": "v{version} · {date}",
"publishButton": "v{version} veröffentlichen",
"publishNoProfile": "Eine Veröffentlichung nennt dich mit deinem öffentlichen Profil. Lege zuerst eines an.",
"publishMakeProfile": "Öffentliches Profil anlegen",
"publishConflicts": "Diese wurden auf zwei Geräten verschieden geändert. Kläre sie zuerst:",
"publishConflictsElsewhere": "Einige Karteikarten, Notizen oder Schritte wurden auf zwei Geräten verschieden geändert. Synchronisiere, dann versuche es noch einmal.",
"publishUntitled": "Ohne Titel",
"publishFilesMissing": "Einige Dateien sind noch nicht auf deiner Instanz angekommen. Versuche es gleich noch einmal.",
"publishNothingChanged": "Seit der letzten Veröffentlichung hat sich nichts geändert.",
"publishBusy": "Dieses Lernset wird schon veröffentlicht. Versuche es gleich noch einmal.",
"publishSlugInvalid": "Kleinbuchstaben, Ziffern und Bindestriche, am Anfang ein Buchstabe oder eine Ziffer.",
"publishSlugTaken": "Ein anderes deiner Lernsets hat diesen Namen.",
"publishNotesTooLong": "Höchstens 5.000 Zeichen.",
"publishDone": "v{version} veröffentlicht.",
"publishFailed": "Deine Instanz war nicht erreichbar. Nichts wurde veröffentlicht.",
"publishUnreachable": "Das Veröffentlichen ist gerade nicht erreichbar.",
"publishRetry": "Erneut versuchen",
"publishCopyLocation": "Adresse kopieren",
"publishLocationCopied": "Adresse kopiert."
} }

View file

@ -1582,5 +1582,51 @@
"cardBrowserResetMessage": "They count as new again. Their reviews stay in your history and streaks.", "cardBrowserResetMessage": "They count as new again. Their reviews stay in your history and streaks.",
"cardBrowserResetConfirm": "Reset", "cardBrowserResetConfirm": "Reset",
"cardBrowserReset": "{count, plural, =1{1 flashcard starts afresh} other{{count} flashcards start afresh}}", "cardBrowserReset": "{count, plural, =1{1 flashcard starts afresh} other{{count} flashcards start afresh}}",
"@cardBrowserReset": {"placeholders": {"count": {"type": "int"}}} "@cardBrowserReset": {"placeholders": {"count": {"type": "int"}}},
"studysetShare": "Share",
"studysetSharePrivate": "Share: private",
"studysetSharePublished": "Share: published",
"publishTitle": "Publish",
"publishPrivate": "Private · only you can see it",
"publishPublished": "Published · v{version} · {date}",
"@publishPublished": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}},
"publishIntro": "A release is this studyset with everything inside it, as it is now. Others can find it, learn from it and copy it. Each release stays as it was; later changes go into the next one.",
"publishAnonymous": "Sign in with GitHub to have a public profile and publish.",
"publishName": "Name",
"publishNameHint": "japanese",
"publishLocation": "Found at {location}",
"@publishLocation": {"placeholders": {"location": {"type": "String"}}},
"publishNameFixed": "Fixed since you first published: others refer to it.",
"publishLicence": "Licence",
"publishLicenceNone": "None",
"publishLicenceOpen": "Allows publishing.",
"publishLicenceNoDerivatives": "Allows publishing as it is: learners keep their changes to themselves.",
"publishLicenceClosed": "Doesn't allow passing it on. Choose another under Edit.",
"publishLicenceNotOpen": "These studysets' licences don't allow passing them on: {titles}",
"@publishLicenceNotOpen": {"placeholders": {"titles": {"type": "String"}}},
"publishNotes": "What changed",
"publishNotesHint": "New in this release…",
"publishReleases": "Releases",
"publishRelease": "v{version} · {date}",
"@publishRelease": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}},
"publishButton": "Publish v{version}",
"@publishButton": {"placeholders": {"version": {"type": "int"}}},
"publishNoProfile": "A release names you by your public profile. Make one first.",
"publishMakeProfile": "Make a public profile",
"publishConflicts": "These were changed differently on two devices. Settle them first:",
"publishConflictsElsewhere": "Some cards, notes or steps were changed differently on two devices. Sync, then try again.",
"publishUntitled": "Untitled",
"publishFilesMissing": "Some files haven't reached your instance yet. Try again in a moment.",
"publishNothingChanged": "Nothing has changed since the last release.",
"publishBusy": "This studyset is being published already. Try again in a moment.",
"publishSlugInvalid": "Lower-case letters, digits and hyphens, starting with a letter or digit.",
"publishSlugTaken": "Another of your studysets has this name.",
"publishNotesTooLong": "At most 5,000 characters.",
"publishDone": "Published v{version}.",
"@publishDone": {"placeholders": {"version": {"type": "int"}}},
"publishFailed": "Couldn't reach your instance. Nothing was published.",
"publishUnreachable": "Publishing can't be reached right now.",
"publishRetry": "Retry",
"publishCopyLocation": "Copy address",
"publishLocationCopied": "Address copied."
} }

View file

@ -19,5 +19,7 @@
"licenseChooserUse": "Use this license", "licenseChooserUse": "Use this license",
"licenseSummaryBySa": "Others may share and adapt it, even commercially, if they credit you and license their adaptations the same way.", "licenseSummaryBySa": "Others may share and adapt it, even commercially, if they credit you and license their adaptations the same way.",
"licenseSummaryByNcSa": "Others may share and adapt it non-commercially, with credit and the same license on adaptations.", "licenseSummaryByNcSa": "Others may share and adapt it non-commercially, with credit and the same license on adaptations.",
"licenseSummaryByNcNd": "Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons license." "licenseSummaryByNcNd": "Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons license.",
"publishLicence": "License",
"publishLicenceNotOpen": "These studysets' licenses don't allow passing them on: {titles}"
} }

View file

@ -8048,6 +8048,246 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count, plural, =1{1 flashcard starts afresh} other{{count} flashcards start afresh}}'** /// **'{count, plural, =1{1 flashcard starts afresh} other{{count} flashcards start afresh}}'**
String cardBrowserReset(int count); String cardBrowserReset(int count);
/// No description provided for @studysetShare.
///
/// In en, this message translates to:
/// **'Share'**
String get studysetShare;
/// No description provided for @studysetSharePrivate.
///
/// In en, this message translates to:
/// **'Share: private'**
String get studysetSharePrivate;
/// No description provided for @studysetSharePublished.
///
/// In en, this message translates to:
/// **'Share: published'**
String get studysetSharePublished;
/// No description provided for @publishTitle.
///
/// In en, this message translates to:
/// **'Publish'**
String get publishTitle;
/// No description provided for @publishPrivate.
///
/// In en, this message translates to:
/// **'Private · only you can see it'**
String get publishPrivate;
/// No description provided for @publishPublished.
///
/// In en, this message translates to:
/// **'Published · v{version} · {date}'**
String publishPublished(int version, String date);
/// No description provided for @publishIntro.
///
/// In en, this message translates to:
/// **'A release is this studyset with everything inside it, as it is now. Others can find it, learn from it and copy it. Each release stays as it was; later changes go into the next one.'**
String get publishIntro;
/// No description provided for @publishAnonymous.
///
/// In en, this message translates to:
/// **'Sign in with GitHub to have a public profile and publish.'**
String get publishAnonymous;
/// No description provided for @publishName.
///
/// In en, this message translates to:
/// **'Name'**
String get publishName;
/// No description provided for @publishNameHint.
///
/// In en, this message translates to:
/// **'japanese'**
String get publishNameHint;
/// No description provided for @publishLocation.
///
/// In en, this message translates to:
/// **'Found at {location}'**
String publishLocation(String location);
/// No description provided for @publishNameFixed.
///
/// In en, this message translates to:
/// **'Fixed since you first published: others refer to it.'**
String get publishNameFixed;
/// No description provided for @publishLicence.
///
/// In en, this message translates to:
/// **'Licence'**
String get publishLicence;
/// No description provided for @publishLicenceNone.
///
/// In en, this message translates to:
/// **'None'**
String get publishLicenceNone;
/// No description provided for @publishLicenceOpen.
///
/// In en, this message translates to:
/// **'Allows publishing.'**
String get publishLicenceOpen;
/// No description provided for @publishLicenceNoDerivatives.
///
/// In en, this message translates to:
/// **'Allows publishing as it is: learners keep their changes to themselves.'**
String get publishLicenceNoDerivatives;
/// No description provided for @publishLicenceClosed.
///
/// In en, this message translates to:
/// **'Doesn\'t allow passing it on. Choose another under Edit.'**
String get publishLicenceClosed;
/// No description provided for @publishLicenceNotOpen.
///
/// In en, this message translates to:
/// **'These studysets\' licences don\'t allow passing them on: {titles}'**
String publishLicenceNotOpen(String titles);
/// No description provided for @publishNotes.
///
/// In en, this message translates to:
/// **'What changed'**
String get publishNotes;
/// No description provided for @publishNotesHint.
///
/// In en, this message translates to:
/// **'New in this release…'**
String get publishNotesHint;
/// No description provided for @publishReleases.
///
/// In en, this message translates to:
/// **'Releases'**
String get publishReleases;
/// No description provided for @publishRelease.
///
/// In en, this message translates to:
/// **'v{version} · {date}'**
String publishRelease(int version, String date);
/// No description provided for @publishButton.
///
/// In en, this message translates to:
/// **'Publish v{version}'**
String publishButton(int version);
/// No description provided for @publishNoProfile.
///
/// In en, this message translates to:
/// **'A release names you by your public profile. Make one first.'**
String get publishNoProfile;
/// No description provided for @publishMakeProfile.
///
/// In en, this message translates to:
/// **'Make a public profile'**
String get publishMakeProfile;
/// No description provided for @publishConflicts.
///
/// In en, this message translates to:
/// **'These were changed differently on two devices. Settle them first:'**
String get publishConflicts;
/// No description provided for @publishConflictsElsewhere.
///
/// In en, this message translates to:
/// **'Some cards, notes or steps were changed differently on two devices. Sync, then try again.'**
String get publishConflictsElsewhere;
/// No description provided for @publishUntitled.
///
/// In en, this message translates to:
/// **'Untitled'**
String get publishUntitled;
/// No description provided for @publishFilesMissing.
///
/// In en, this message translates to:
/// **'Some files haven\'t reached your instance yet. Try again in a moment.'**
String get publishFilesMissing;
/// No description provided for @publishNothingChanged.
///
/// In en, this message translates to:
/// **'Nothing has changed since the last release.'**
String get publishNothingChanged;
/// No description provided for @publishBusy.
///
/// In en, this message translates to:
/// **'This studyset is being published already. Try again in a moment.'**
String get publishBusy;
/// No description provided for @publishSlugInvalid.
///
/// In en, this message translates to:
/// **'Lower-case letters, digits and hyphens, starting with a letter or digit.'**
String get publishSlugInvalid;
/// No description provided for @publishSlugTaken.
///
/// In en, this message translates to:
/// **'Another of your studysets has this name.'**
String get publishSlugTaken;
/// No description provided for @publishNotesTooLong.
///
/// In en, this message translates to:
/// **'At most 5,000 characters.'**
String get publishNotesTooLong;
/// No description provided for @publishDone.
///
/// In en, this message translates to:
/// **'Published v{version}.'**
String publishDone(int version);
/// No description provided for @publishFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t reach your instance. Nothing was published.'**
String get publishFailed;
/// No description provided for @publishUnreachable.
///
/// In en, this message translates to:
/// **'Publishing can\'t be reached right now.'**
String get publishUnreachable;
/// No description provided for @publishRetry.
///
/// In en, this message translates to:
/// **'Retry'**
String get publishRetry;
/// No description provided for @publishCopyLocation.
///
/// In en, this message translates to:
/// **'Copy address'**
String get publishCopyLocation;
/// No description provided for @publishLocationCopied.
///
/// In en, this message translates to:
/// **'Address copied.'**
String get publishLocationCopied;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -5039,4 +5039,151 @@ class AppLocalizationsDe extends AppLocalizations {
); );
return '$_temp0'; return '$_temp0';
} }
@override
String get studysetShare => 'Teilen';
@override
String get studysetSharePrivate => 'Teilen: privat';
@override
String get studysetSharePublished => 'Teilen: veröffentlicht';
@override
String get publishTitle => 'Veröffentlichen';
@override
String get publishPrivate => 'Privat · nur du siehst es';
@override
String publishPublished(int version, String date) {
return 'Veröffentlicht · v$version · $date';
}
@override
String get publishIntro =>
'Eine Veröffentlichung ist dieses Lernset mit allem darin, so wie es jetzt ist. Andere können es finden, damit lernen und es kopieren. Jede Veröffentlichung bleibt, wie sie war; spätere Änderungen kommen in die nächste.';
@override
String get publishAnonymous =>
'Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.';
@override
String get publishName => 'Name';
@override
String get publishNameHint => 'japanisch';
@override
String publishLocation(String location) {
return 'Zu finden unter $location';
}
@override
String get publishNameFixed =>
'Fest, seit du zuerst veröffentlicht hast: andere verweisen darauf.';
@override
String get publishLicence => 'Lizenz';
@override
String get publishLicenceNone => 'Keine';
@override
String get publishLicenceOpen => 'Erlaubt das Veröffentlichen.';
@override
String get publishLicenceNoDerivatives =>
'Erlaubt das Veröffentlichen, wie es ist: Lernende behalten ihre Änderungen für sich.';
@override
String get publishLicenceClosed =>
'Erlaubt keine Weitergabe. Wähle unter Bearbeiten eine andere.';
@override
String publishLicenceNotOpen(String titles) {
return 'Die Lizenzen dieser Lernsets erlauben keine Weitergabe: $titles';
}
@override
String get publishNotes => 'Was sich geändert hat';
@override
String get publishNotesHint => 'Neu in dieser Veröffentlichung …';
@override
String get publishReleases => 'Veröffentlichungen';
@override
String publishRelease(int version, String date) {
return 'v$version · $date';
}
@override
String publishButton(int version) {
return 'v$version veröffentlichen';
}
@override
String get publishNoProfile =>
'Eine Veröffentlichung nennt dich mit deinem öffentlichen Profil. Lege zuerst eines an.';
@override
String get publishMakeProfile => 'Öffentliches Profil anlegen';
@override
String get publishConflicts =>
'Diese wurden auf zwei Geräten verschieden geändert. Kläre sie zuerst:';
@override
String get publishConflictsElsewhere =>
'Einige Karteikarten, Notizen oder Schritte wurden auf zwei Geräten verschieden geändert. Synchronisiere, dann versuche es noch einmal.';
@override
String get publishUntitled => 'Ohne Titel';
@override
String get publishFilesMissing =>
'Einige Dateien sind noch nicht auf deiner Instanz angekommen. Versuche es gleich noch einmal.';
@override
String get publishNothingChanged =>
'Seit der letzten Veröffentlichung hat sich nichts geändert.';
@override
String get publishBusy =>
'Dieses Lernset wird schon veröffentlicht. Versuche es gleich noch einmal.';
@override
String get publishSlugInvalid =>
'Kleinbuchstaben, Ziffern und Bindestriche, am Anfang ein Buchstabe oder eine Ziffer.';
@override
String get publishSlugTaken =>
'Ein anderes deiner Lernsets hat diesen Namen.';
@override
String get publishNotesTooLong => 'Höchstens 5.000 Zeichen.';
@override
String publishDone(int version) {
return 'v$version veröffentlicht.';
}
@override
String get publishFailed =>
'Deine Instanz war nicht erreichbar. Nichts wurde veröffentlicht.';
@override
String get publishUnreachable =>
'Das Veröffentlichen ist gerade nicht erreichbar.';
@override
String get publishRetry => 'Erneut versuchen';
@override
String get publishCopyLocation => 'Adresse kopieren';
@override
String get publishLocationCopied => 'Adresse kopiert.';
} }

View file

@ -4998,6 +4998,151 @@ class AppLocalizationsEn extends AppLocalizations {
); );
return '$_temp0'; return '$_temp0';
} }
@override
String get studysetShare => 'Share';
@override
String get studysetSharePrivate => 'Share: private';
@override
String get studysetSharePublished => 'Share: published';
@override
String get publishTitle => 'Publish';
@override
String get publishPrivate => 'Private · only you can see it';
@override
String publishPublished(int version, String date) {
return 'Published · v$version · $date';
}
@override
String get publishIntro =>
'A release is this studyset with everything inside it, as it is now. Others can find it, learn from it and copy it. Each release stays as it was; later changes go into the next one.';
@override
String get publishAnonymous =>
'Sign in with GitHub to have a public profile and publish.';
@override
String get publishName => 'Name';
@override
String get publishNameHint => 'japanese';
@override
String publishLocation(String location) {
return 'Found at $location';
}
@override
String get publishNameFixed =>
'Fixed since you first published: others refer to it.';
@override
String get publishLicence => 'Licence';
@override
String get publishLicenceNone => 'None';
@override
String get publishLicenceOpen => 'Allows publishing.';
@override
String get publishLicenceNoDerivatives =>
'Allows publishing as it is: learners keep their changes to themselves.';
@override
String get publishLicenceClosed =>
'Doesn\'t allow passing it on. Choose another under Edit.';
@override
String publishLicenceNotOpen(String titles) {
return 'These studysets\' licences don\'t allow passing them on: $titles';
}
@override
String get publishNotes => 'What changed';
@override
String get publishNotesHint => 'New in this release…';
@override
String get publishReleases => 'Releases';
@override
String publishRelease(int version, String date) {
return 'v$version · $date';
}
@override
String publishButton(int version) {
return 'Publish v$version';
}
@override
String get publishNoProfile =>
'A release names you by your public profile. Make one first.';
@override
String get publishMakeProfile => 'Make a public profile';
@override
String get publishConflicts =>
'These were changed differently on two devices. Settle them first:';
@override
String get publishConflictsElsewhere =>
'Some cards, notes or steps were changed differently on two devices. Sync, then try again.';
@override
String get publishUntitled => 'Untitled';
@override
String get publishFilesMissing =>
'Some files haven\'t reached your instance yet. Try again in a moment.';
@override
String get publishNothingChanged =>
'Nothing has changed since the last release.';
@override
String get publishBusy =>
'This studyset is being published already. Try again in a moment.';
@override
String get publishSlugInvalid =>
'Lower-case letters, digits and hyphens, starting with a letter or digit.';
@override
String get publishSlugTaken => 'Another of your studysets has this name.';
@override
String get publishNotesTooLong => 'At most 5,000 characters.';
@override
String publishDone(int version) {
return 'Published v$version.';
}
@override
String get publishFailed =>
'Couldn\'t reach your instance. Nothing was published.';
@override
String get publishUnreachable => 'Publishing can\'t be reached right now.';
@override
String get publishRetry => 'Retry';
@override
String get publishCopyLocation => 'Copy address';
@override
String get publishLocationCopied => 'Address copied.';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).
@ -5075,4 +5220,12 @@ class AppLocalizationsEnUs extends AppLocalizationsEn {
@override @override
String get homeCustomise => 'Customize'; String get homeCustomise => 'Customize';
@override
String get publishLicence => 'License';
@override
String publishLicenceNotOpen(String titles) {
return 'These studysets\' licenses don\'t allow passing them on: $titles';
}
} }

View file

@ -49,6 +49,8 @@ import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platfor
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart'; import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/serverpod_profile_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/profile/data/serverpod_profile_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/serverpod_publish_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
@ -301,6 +303,7 @@ Future<PersonDependencies> _openPerson(
File(p.join(folder.path, 'pomodoro.json')), File(p.join(folder.path, 'pomodoro.json')),
), ),
profile: ProfileRepository(ServerpodProfileGateway(client)), profile: ProfileRepository(ServerpodProfileGateway(client)),
publishing: PublishRepository(ServerpodPublishGateway(client)),
onDispose: () async { onDispose: () async {
try { try {
// Deletions first: those still waiting are dropped, those under way // Deletions first: those still waiting are dropped, those under way

View file

@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_deletions.
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart'; import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
@ -79,6 +80,9 @@ class PersonDependencies({
/// The person's public profile, on the instance. /// The person's public profile, on the instance.
required final ProfileRepository profile, required final ProfileRepository profile,
/// Publishing the person's studysets, on the instance.
required final PublishRepository publishing,
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,
}) { }) {
/// Closes what was opened for the person. /// Closes what was opened for the person.

View file

@ -0,0 +1,270 @@
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/unsettled_item.dart';
/// Publishing the studyset [_studysetId]: where it stands, and its next
/// release.
///
/// The instance builds a release from what it holds, so publishing syncs
/// first ([_sync]). It refuses a card, note or step with more than one
/// current revision, which the app merges side by side (ADR 0003) and the
/// instance doesn't: those whose changes merge are saved merged here, and
/// publishing tries once more; those changed differently on the same side
/// are left to the person, as [PublishStateReady.unsettled].
class PublishBloc(
final PublishRepository _publishing,
final ProfileRepository _profiles,
final CardRepository _cards,
final NoteRepository _notes,
final LearningPathRepository _paths, {
required final String _studysetId,
required final Future<void> Function() _sync,
}) extends Bloc<PublishEvent, PublishState> {
this : super(const PublishStateLoading()) {
on<PublishEventStarted>(_onStarted);
on<PublishEventRequested>(_onRequested);
}
Future<void> _onStarted(
PublishEventStarted event,
Emitter<PublishState> emit,
) async {
emit(const PublishStateLoading());
try {
final (publication, profile) = await (
_publishing.publication(_studysetId),
_profiles.mine(),
).wait;
emit(PublishStateReady(publication: publication, profile: profile));
} on Object catch (error) {
log('Reading the publication failed', name: 'Publish', error: error);
emit(const PublishStateUnreachable());
}
}
Future<void> _onRequested(
PublishEventRequested event,
Emitter<PublishState> emit,
) async {
if (state case final PublishStateReady ready when !ready.publishing) {
emit(ready.next(publishing: true));
Future<Publication> publish() async {
await _sync();
return await _publishing.publish(
_studysetId,
slug: event.slug,
notes: event.notes,
);
}
try {
Publication published;
try {
published = await publish();
} on PublishRefused catch (refused) {
if (refused.problem != PublishProblem.conflicts ||
(await _settle(
refused.items.toSet(),
event.subtree,
)).isNotEmpty) {
rethrow;
}
published = await publish();
}
emit(
PublishStateReady(
publication: published,
profile: ready.profile,
outcome: PublishOutcome.published,
),
);
} on PublishRefused catch (refused) {
emit(
ready.next(
problem: refused.problem,
items: refused.items,
unsettled: refused.problem == PublishProblem.conflicts
? await _unsettled(refused.items.toSet(), event.subtree)
: const [],
),
);
} on Object catch (error) {
log('Publishing failed', name: 'Publish', error: error);
emit(ready.next(outcome: PublishOutcome.failed));
}
}
}
/// Saves merged each of [items] in the studysets [subtree] whose current
/// revisions merge without a conflict, and returns those that don't.
Future<List<UnsettledItem>> _settle(
Set<String> items,
List<String> subtree,
) => _visit(items, subtree, merge: true);
/// Those of [items] in the studysets [subtree] with a conflict to settle.
Future<List<UnsettledItem>> _unsettled(
Set<String> items,
List<String> subtree,
) => _visit(items, subtree, merge: false);
Future<List<UnsettledItem>> _visit(
Set<String> items,
List<String> subtree, {
required bool merge,
}) async {
final unsettled = <UnsettledItem>[];
for (final studysetId in subtree) {
for (final card in await _cards.cardsOf(studysetId)) {
if (!items.contains(card.id)) {
continue;
}
if (card.hasConflict) {
unsettled.add(
UnsettledItem(
kind: UnsettledKind.card,
id: card.id,
studysetId: studysetId,
title: card.content.front.plainText.trim(),
),
);
} else if (merge) {
await _cards.saveEdit(card.id, card.content, basedOn: card.heads);
}
}
for (final note in await _notes.notesOf(studysetId)) {
if (!items.contains(note.id)) {
continue;
}
if (note.hasConflict) {
unsettled.add(
UnsettledItem(
kind: UnsettledKind.note,
id: note.id,
studysetId: studysetId,
title: note.content.title,
),
);
} else if (merge) {
await _notes.saveEdit(note.id, note.content, basedOn: note.heads);
}
}
for (final path in await _paths.pathsOf(studysetId)) {
for (final step in await _paths.stepsOf(path.id)) {
if (!items.contains(step.id)) {
continue;
}
if (step.hasConflict) {
unsettled.add(
UnsettledItem(
kind: UnsettledKind.step,
id: step.id,
studysetId: studysetId,
pathId: path.id,
title: step.content.title,
),
);
} else if (merge) {
await _paths.saveStep(step.id, step.content, basedOn: step.heads);
}
}
}
}
return unsettled;
}
}
/// What publishing came to, said once.
enum PublishOutcome() {
/// A new release is out.
published,
/// The instance couldn't be reached; nothing was published.
failed,
}
/// Where publishing the studyset stands.
sealed class const PublishState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Being read.
final class const PublishStateLoading() extends PublishState;
/// The instance can't be reached.
final class const PublishStateUnreachable() extends PublishState;
/// Read: the studyset as published ([publication], null while it never
/// was), the person's public [profile] (null while they have none), whether
/// a release is on its way, why the last try was refused ([problem], about
/// [items]; the cards, notes and steps to settle as [unsettled]), and what
/// it came to ([outcome]); the last four cleared by the next try.
final class const PublishStateReady({
final Publication? publication,
final PublicProfile? profile,
final bool publishing = false,
final PublishProblem? problem,
final List<String> items = const [],
final List<UnsettledItem> unsettled = const [],
final PublishOutcome? outcome,
}) extends PublishState {
/// This state with [publishing], [problem], [items], [unsettled] and
/// [outcome] as given.
PublishStateReady next({
bool publishing = false,
PublishProblem? problem,
List<String> items = const [],
List<UnsettledItem> unsettled = const [],
PublishOutcome? outcome,
}) => PublishStateReady(
publication: publication,
profile: profile,
publishing: publishing,
problem: problem,
items: items,
unsettled: unsettled,
outcome: outcome,
);
@override
List<Object?> get props => [
publication,
profile,
publishing,
problem,
items,
unsettled,
outcome,
];
}
/// What happens to publishing the studyset.
sealed class const PublishEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The sheet opens: read where publishing stands.
final class const PublishEventStarted() extends PublishEvent;
/// The person publishes the next release, named [slug] (fixed after the
/// first), [notes] saying what changed; [subtree] the studyset and those
/// inside it, where the cards, notes and steps to settle are looked for.
final class const PublishEventRequested({
required final String slug,
required final String notes,
required final List<String> subtree,
}) extends PublishEvent {
@override
List<Object?> get props => [slug, notes, subtree];
}

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