feat: files in an order of one's own, with Reorder beside the tabs

A PDF now has a position among its studyset's files, as notes and paths
have: a fractional index in a column of its own (so moving it on one
device and renaming it on another don't overwrite each other), last
writer wins. The migration adds it to pdf_source on the server and on
each device with 'a0', so PDFs from before stand in the order they were
added (ties sort by id), and new ones go at the end.

The Files tab lists them in that order by default (Custom order, first
in the Sort menu; newest first was the default before). With two files
or more, Reorder beside the tabs shows the custom order whatever they
were sorted by, a grip in place of each file's options and the Sort menu
put away, until Done.

The next release changes synced tables anyway (card.buriedAt, since
v0.2.2), so older apps have to update for that already.

Also: card.position's doc no longer speaks of moving cards.

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-08 19:28:05 +02:00
commit 7975586994
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 21780 additions and 44 deletions

View file

@ -7,9 +7,9 @@ database: sync
fields:
### The studyset the card belongs to; the card goes with it.
studyset: Studyset?, relation(onDelete=Cascade, deferred)
### Where the card stands in its studyset: a fractional index, so a card can
### move between two others without renumbering the rest; last writer wins
### (decided 2026-09-30).
### Where the card stands in its studyset: a fractional index (decided
### 2026-09-30), set when the card is created or imported, the order new cards
### and cramming follow. Cards aren't reordered by hand (decided 2026-10-08).
position: String
### When the card was buried (set aside), in UTC; null while it isn't. It
### isn't studied until the next local day begins on the device looking, so

View file

@ -74,9 +74,9 @@ abstract class Card
/// The studyset the card belongs to; the card goes with it.
_ie5k1jc2.Studyset? studyset;
/// Where the card stands in its studyset: a fractional index, so a card can
/// move between two others without renumbering the rest; last writer wins
/// (decided 2026-09-30).
/// Where the card stands in its studyset: a fractional index (decided
/// 2026-09-30), set when the card is created or imported, the order new cards
/// and cramming follow. Cards aren't reordered by hand (decided 2026-10-08).
String position;
/// When the card was buried (set aside), in UTC; null while it isn't. It
@ -256,9 +256,9 @@ class CardTable extends _is.Table<_is.UuidValue?> {
/// The studyset the card belongs to; the card goes with it.
_ie5k1jc2.StudysetTable? _studyset;
/// Where the card stands in its studyset: a fractional index, so a card can
/// move between two others without renumbering the rest; last writer wins
/// (decided 2026-09-30).
/// Where the card stands in its studyset: a fractional index (decided
/// 2026-09-30), set when the card is created or imported, the order new cards
/// and cramming follow. Cards aren't reordered by hand (decided 2026-10-08).
late final _is.ColumnString position;
/// When the card was buried (set aside), in UTC; null while it isn't. It

View file

@ -30,16 +30,18 @@ abstract class PdfSource
this.spaceId,
required this.studysetId,
this.studyset,
String? position,
this.keyId,
required this.payload,
this.nonce,
});
}) : position = position ?? 'a0';
factory PdfSource({
_is.UuidValue? id,
int? spaceId,
required _is.UuidValue studysetId,
_ie5k1jc2.Studyset? studyset,
String? position,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
@ -59,6 +61,7 @@ abstract class PdfSource
: _i5lsenq5.Protocol().deserialize<_ie5k1jc2.Studyset>(
jsonSerialization['studyset'],
),
position: jsonSerialization['position'] as String?,
keyId: jsonSerialization['keyId'] as String?,
payload: _is.ByteDataJsonExtension.fromJson(jsonSerialization['payload']),
nonce: jsonSerialization['nonce'] == null
@ -82,6 +85,13 @@ abstract class PdfSource
/// The studyset the PDF belongs to; the PDF goes with it.
_ie5k1jc2.Studyset? studyset;
/// Where the PDF stands among its studyset's files, as the person put them
/// with Reorder: a fractional index, like a note's; last writer wins (decided
/// 2026-10-08). A column of its own, so that moving it on one device and
/// renaming it on another don't overwrite each other. PDFs from before this
/// column all start at 'a0'; ties sort by id, the order they were added in.
String position;
/// The encryption key's id; always null (ADR 0002, rule 6).
String? keyId;
@ -104,6 +114,7 @@ abstract class PdfSource
int? spaceId,
_is.UuidValue? studysetId,
_ie5k1jc2.Studyset? studyset,
String? position,
String? keyId,
_idt.ByteData? payload,
_idt.ByteData? nonce,
@ -116,6 +127,7 @@ abstract class PdfSource
if (spaceId != null) 'spaceId': spaceId,
'studysetId': studysetId.toJson(),
if (studyset != null) 'studyset': studyset?.toJson(),
'position': position,
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
@ -130,6 +142,7 @@ abstract class PdfSource
if (spaceId != null) 'spaceId': spaceId,
'studysetId': studysetId.toJson(),
if (studyset != null) 'studyset': studyset?.toJsonForProtocol(),
'position': position,
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
@ -172,6 +185,7 @@ class _PdfSourceImpl extends PdfSource {
int? spaceId,
required _is.UuidValue studysetId,
_ie5k1jc2.Studyset? studyset,
String? position,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
@ -180,6 +194,7 @@ class _PdfSourceImpl extends PdfSource {
spaceId: spaceId,
studysetId: studysetId,
studyset: studyset,
position: position,
keyId: keyId,
payload: payload,
nonce: nonce,
@ -194,6 +209,7 @@ class _PdfSourceImpl extends PdfSource {
Object? spaceId = _Undefined,
_is.UuidValue? studysetId,
Object? studyset = _Undefined,
String? position,
Object? keyId = _Undefined,
_idt.ByteData? payload,
Object? nonce = _Undefined,
@ -205,6 +221,7 @@ class _PdfSourceImpl extends PdfSource {
studyset: studyset is _ie5k1jc2.Studyset?
? studyset
: this.studyset?.copyWith(),
position: position ?? this.position,
keyId: keyId is String? ? keyId : this.keyId,
payload: payload ?? this.payload.clone(),
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
@ -227,6 +244,11 @@ class PdfSourceUpdateTable extends _is.UpdateTable<PdfSourceTable> {
value,
);
_is.ColumnValue<String, String> position(String value) => _is.ColumnValue(
table.position,
value,
);
_is.ColumnValue<String, String> keyId(String? value) => _is.ColumnValue(
table.keyId,
value,
@ -256,6 +278,11 @@ class PdfSourceTable extends _is.Table<_is.UuidValue?> {
'studysetId',
this,
);
position = _is.ColumnString(
'position',
this,
hasDefault: true,
);
keyId = _is.ColumnString(
'keyId',
this,
@ -280,6 +307,13 @@ class PdfSourceTable extends _is.Table<_is.UuidValue?> {
/// The studyset the PDF belongs to; the PDF goes with it.
_ie5k1jc2.StudysetTable? _studyset;
/// Where the PDF stands among its studyset's files, as the person put them
/// with Reorder: a fractional index, like a note's; last writer wins (decided
/// 2026-10-08). A column of its own, so that moving it on one device and
/// renaming it on another don't overwrite each other. PDFs from before this
/// column all start at 'a0'; ties sort by id, the order they were added in.
late final _is.ColumnString position;
/// The encryption key's id; always null (ADR 0002, rule 6).
late final _is.ColumnString keyId;
@ -309,6 +343,7 @@ class PdfSourceTable extends _is.Table<_is.UuidValue?> {
id,
spaceId,
studysetId,
position,
keyId,
payload,
nonce,

View file

@ -1520,6 +1520,13 @@ class Protocol extends _is.DatabaseSerializationManager {
isNullable: false,
dartType: 'UuidValue',
),
_isp.ColumnDefinition(
name: 'position',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
columnDefault: '\'a0\'',
),
_isp.ColumnDefinition(
name: 'keyId',
columnType: _isp.ColumnType.text,
@ -1584,6 +1591,10 @@ class Protocol extends _is.DatabaseSerializationManager {
type: _isp.IndexElementDefinitionType.column,
definition: 'studysetId',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'position',
),
],
type: 'btree',
isUnique: false,

View file

@ -11,6 +11,12 @@ database: sync
fields:
### The studyset the PDF belongs to; the PDF goes with it.
studyset: Studyset?, relation(onDelete=Cascade, deferred)
### Where the PDF stands among its studyset's files, as the person put them
### with Reorder: a fractional index, like a note's; last writer wins (decided
### 2026-10-08). A column of its own, so that moving it on one device and
### renaming it on another don't overwrite each other. PDFs from before this
### column all start at 'a0'; ties sort by id, the order they were added in.
position: String, default='a0'
### The encryption key's id; always null (ADR 0002, rule 6).
keyId: String?
### The content as UTF-8 JSON: {"schemaVersion", "title", "fileName", "asset",
@ -23,6 +29,6 @@ indexes:
# Deleting a space finds its rows through this index (see studyset.spy.yaml).
pdf_source_space_idx:
fields: spaceId
# A studyset's PDFs.
# A studyset's PDFs, in order.
pdf_source_studyset_idx:
fields: studysetId
fields: studysetId, position

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,56 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "pdf_source",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "position",
"fieldName": "position",
"columnType": 0,
"isNullable": false,
"columnDefault": "'a0'",
"dartType": "String"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "pdf_source_studyset_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "studysetId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "position"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"deleteIndexes": [
"pdf_source_studyset_idx"
],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,51 @@
BEGIN;
--
-- ACTION ALTER TABLE
--
DROP INDEX "pdf_source_studyset_idx";
ALTER TABLE "pdf_source" ADD COLUMN "position" text NOT NULL DEFAULT 'a0'::text;
CREATE INDEX "pdf_source_studyset_idx" ON "pdf_source" USING btree ("studysetId", "position");
--
-- MIGRATION VERSION FOR halkyon_learn
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_learn', '20261008165051407-pdf-position', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261008165051407-pdf-position', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_offline_sync
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync', '20260914143806119', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260914143806119', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -14,3 +14,4 @@
20261002211346701-learning-paths
20261003110855698-pdf-sources
20261007205440868-card-buried-at
20261008165051407-pdf-position