feat(todo,app): files attached to tasks and comments
Halkyon's ADR 0043, D1, D3, D5 and D8: any file up to 100 MB, attached to a task's description or to a comment, sealed as everything of Todo's. Server: the attachment table, synced and sealed as the others, with the sealed file's hash readable for the store; todoFiles hands halkyon_base its rows, readable by their space's members, and takes sealed files alone; the files no attachment names go daily. Its tests hold downloads to the row's space, refuse a readable file and prune what no live row names. Migration attachments: CREATE TABLE "attachment" with its indexes and the foreign key to its space, cascading as every synced table's. App: AttachmentRepository seals each file for its row, keeps it sealed and opened in the person's folder, uploads it after each sync and fetches it as it shows, both held back by Without files on mobile data but for a file tapped. Under the description and a comment's text, a row of them: pictures as thumbnails opening full-size, other files as chips opening in the system's app, else saved; the paperclip beside the description and in the comment's composer adds them, the composer holding them until it is sent. A task deleted takes its files and its comments' along, a comment its own. halkyon_app opens a file in the system's app (FileOpener): on Android through its plugin and a FileProvider of its own class, sharing the cache's halkyon_opened/ folder alone; on the desktop as a file: URI. Whats-New: Attach files to a task's description or a comment with the paperclip: pictures show as thumbnails, other files open in their app. Whats-New-De: Hänge mit der Büroklammer Dateien an die Beschreibung einer Aufgabe oder an einen Kommentar: Bilder erscheinen als Vorschau, andere Dateien öffnen sich in ihrer App. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5dd7575dd6
commit
3ca4b4308d
48 changed files with 23300 additions and 62 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,806 @@
|
|||
{
|
||||
"__className__": "serverpod.DatabaseDefinition",
|
||||
"schemaVersion": 2,
|
||||
"moduleName": "halkyon_todo",
|
||||
"tables": [
|
||||
{
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "attachment",
|
||||
"dartName": "Attachment",
|
||||
"module": "halkyon_todo",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "sha256",
|
||||
"fieldName": "sha256",
|
||||
"columnType": 0,
|
||||
"isNullable": false,
|
||||
"dartType": "String"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "keyId",
|
||||
"fieldName": "keyId",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "payload",
|
||||
"fieldName": "payload",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 5,
|
||||
"isNullable": true,
|
||||
"dartType": "dart:typed_data:ByteData?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "attachment_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "attachment_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "attachment_hash_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "sha256"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "content_key",
|
||||
"dartName": "ContentKey",
|
||||
"module": "halkyon_todo",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "dataKeyId",
|
||||
"fieldName": "dataKeyId",
|
||||
"columnType": 0,
|
||||
"isNullable": false,
|
||||
"dartType": "String"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "sealedKey",
|
||||
"fieldName": "sealedKey",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "content_key_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "content_key_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "label",
|
||||
"dartName": "Label",
|
||||
"module": "halkyon_todo",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "keyId",
|
||||
"fieldName": "keyId",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "payload",
|
||||
"fieldName": "payload",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 5,
|
||||
"isNullable": true,
|
||||
"dartType": "dart:typed_data:ByteData?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "label_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "label_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "project",
|
||||
"dartName": "Project",
|
||||
"module": "halkyon_todo",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "keyId",
|
||||
"fieldName": "keyId",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "payload",
|
||||
"fieldName": "payload",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 5,
|
||||
"isNullable": true,
|
||||
"dartType": "dart:typed_data:ByteData?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "project_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "project_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "saved_filter",
|
||||
"dartName": "SavedFilter",
|
||||
"module": "halkyon_todo",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "keyId",
|
||||
"fieldName": "keyId",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "payload",
|
||||
"fieldName": "payload",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 5,
|
||||
"isNullable": true,
|
||||
"dartType": "dart:typed_data:ByteData?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "saved_filter_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "saved_filter_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "section",
|
||||
"dartName": "Section",
|
||||
"module": "halkyon_todo",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "keyId",
|
||||
"fieldName": "keyId",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "payload",
|
||||
"fieldName": "payload",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 5,
|
||||
"isNullable": true,
|
||||
"dartType": "dart:typed_data:ByteData?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "section_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "section_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "task",
|
||||
"dartName": "Task",
|
||||
"module": "halkyon_todo",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "keyId",
|
||||
"fieldName": "keyId",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "payload",
|
||||
"fieldName": "payload",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 5,
|
||||
"isNullable": true,
|
||||
"dartType": "dart:typed_data:ByteData?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "task_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "task_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "task_comment",
|
||||
"dartName": "TaskComment",
|
||||
"module": "halkyon_todo",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "keyId",
|
||||
"fieldName": "keyId",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "payload",
|
||||
"fieldName": "payload",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 5,
|
||||
"isNullable": true,
|
||||
"dartType": "dart:typed_data:ByteData?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "task_comment_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "task_comment_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "todo_settings",
|
||||
"dartName": "TodoSettings",
|
||||
"module": "halkyon_todo",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "keyId",
|
||||
"fieldName": "keyId",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "payload",
|
||||
"fieldName": "payload",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 5,
|
||||
"isNullable": true,
|
||||
"dartType": "dart:typed_data:ByteData?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "todo_settings_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "todo_settings_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
}
|
||||
],
|
||||
"installedModules": [
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationVersion",
|
||||
"module": "halkyon_base",
|
||||
"version": "20261110225132890-file-store"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationVersion",
|
||||
"module": "serverpod",
|
||||
"version": "20260824182259319"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationVersion",
|
||||
"module": "serverpod_auth_idp",
|
||||
"version": "20260924105404509"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationVersion",
|
||||
"module": "serverpod_offline_sync",
|
||||
"version": "20260914143806119"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationVersion",
|
||||
"module": "halkyon_oidc",
|
||||
"version": "20261023180644988"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationVersion",
|
||||
"module": "serverpod_auth_core",
|
||||
"version": "20260924105232991"
|
||||
}
|
||||
],
|
||||
"migrationApiVersion": 1
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,121 @@
|
|||
{
|
||||
"__className__": "serverpod.DatabaseMigration",
|
||||
"actions": [
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationAction",
|
||||
"type": "createTable",
|
||||
"createTable": {
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "attachment",
|
||||
"dartName": "Attachment",
|
||||
"module": "halkyon_todo",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "sha256",
|
||||
"fieldName": "sha256",
|
||||
"columnType": 0,
|
||||
"isNullable": false,
|
||||
"dartType": "String"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "keyId",
|
||||
"fieldName": "keyId",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "payload",
|
||||
"fieldName": "payload",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 5,
|
||||
"isNullable": true,
|
||||
"dartType": "dart:typed_data:ByteData?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "attachment_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "attachment_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "attachment_hash_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "sha256"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"migrationApiVersion": 1
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ part '20261101180755366-settings/migration.dart';
|
|||
part '20261106183802735-filters/migration.dart';
|
||||
part '20261106184947570-comments/migration.dart';
|
||||
part '20261110225635732-file-store/migration.dart';
|
||||
part '20261110231607673-attachments/migration.dart';
|
||||
|
||||
/// Migration registry for the client-side database.
|
||||
class MigrationRegistry {
|
||||
|
|
@ -22,6 +23,7 @@ class MigrationRegistry {
|
|||
_Migration20261106183802735_filters(),
|
||||
_Migration20261106184947570_comments(),
|
||||
_Migration20261110225635732_file_store(),
|
||||
_Migration20261110231607673_attachments(),
|
||||
];
|
||||
|
||||
/// List of all client-side database migration versions.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,705 @@
|
|||
/* 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 'dart:typed_data' as _idt;
|
||||
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||
import 'package:serverpod_database/serverpod_database.dart' as _isd;
|
||||
|
||||
/// A file attached to a task's description or to a comment (Halkyon's ADR
|
||||
/// 0043, D8): its name, media type and size, and what it is attached to, in
|
||||
/// its sealed payload (ADR 0033, D5); the file itself is sealed too, kept
|
||||
/// in halkyon_base's store (D2) and fetched when shown. The instance sees
|
||||
/// that it exists, its size and the sealed file's hash, not what it is
|
||||
/// attached to. Never changed: deleted with what it is attached to, or on
|
||||
/// its own. Synced.
|
||||
abstract class Attachment
|
||||
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {
|
||||
Attachment._({
|
||||
this.id,
|
||||
this.spaceId,
|
||||
required this.sha256,
|
||||
this.keyId,
|
||||
required this.payload,
|
||||
this.nonce,
|
||||
});
|
||||
|
||||
factory Attachment({
|
||||
_isc.UuidValue? id,
|
||||
int? spaceId,
|
||||
required String sha256,
|
||||
String? keyId,
|
||||
required _idt.ByteData payload,
|
||||
_idt.ByteData? nonce,
|
||||
}) = _AttachmentImpl;
|
||||
|
||||
factory Attachment.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return Attachment(
|
||||
id: jsonSerialization['id'] == null
|
||||
? null
|
||||
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
|
||||
spaceId: jsonSerialization['spaceId'] as int?,
|
||||
sha256: jsonSerialization['sha256'] as String,
|
||||
keyId: jsonSerialization['keyId'] as String?,
|
||||
payload: _isc.ByteDataJsonExtension.fromJson(
|
||||
jsonSerialization['payload'],
|
||||
),
|
||||
nonce: jsonSerialization['nonce'] == null
|
||||
? null
|
||||
: _isc.ByteDataJsonExtension.fromJson(jsonSerialization['nonce']),
|
||||
);
|
||||
}
|
||||
|
||||
static final t = AttachmentTable();
|
||||
|
||||
static const db = AttachmentRepository._();
|
||||
|
||||
@override
|
||||
_isc.UuidValue? id;
|
||||
|
||||
/// The space owning this row. Maintained by the sync engine.
|
||||
int? spaceId;
|
||||
|
||||
/// The sealed file's SHA-256, in lowercase hex: its name in the store,
|
||||
/// which the server reads to serve it and to count and prune the files
|
||||
/// (`todo_files.dart`). The hash of sealed bytes says nothing of the file.
|
||||
String sha256;
|
||||
|
||||
/// The key it is sealed with (ADR 0002's envelope); never null in Todo,
|
||||
/// where everything is sealed.
|
||||
String? keyId;
|
||||
|
||||
/// The content, sealed: UTF-8 JSON with a `schemaVersion` once opened.
|
||||
_idt.ByteData payload;
|
||||
|
||||
/// The sealing's nonce (ADR 0002's envelope).
|
||||
_idt.ByteData? nonce;
|
||||
|
||||
@override
|
||||
_isd.Table<_isc.UuidValue?> get table => t;
|
||||
|
||||
/// Returns a shallow copy of this [Attachment]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
Attachment copyWith({
|
||||
_isc.UuidValue? id,
|
||||
int? spaceId,
|
||||
String? sha256,
|
||||
String? keyId,
|
||||
_idt.ByteData? payload,
|
||||
_idt.ByteData? nonce,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'Attachment',
|
||||
if (id != null) 'id': id?.toJson(),
|
||||
if (spaceId != null) 'spaceId': spaceId,
|
||||
'sha256': sha256,
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
'payload': payload.toJson(),
|
||||
if (nonce != null) 'nonce': nonce?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'Attachment',
|
||||
if (id != null) 'id': id?.toJson(),
|
||||
if (spaceId != null) 'spaceId': spaceId,
|
||||
'sha256': sha256,
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
'payload': payload.toJson(),
|
||||
if (nonce != null) 'nonce': nonce?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
static AttachmentInclude include() {
|
||||
return AttachmentInclude._();
|
||||
}
|
||||
|
||||
static AttachmentIncludeList includeList({
|
||||
_isd.WhereExpressionBuilder<AttachmentTable>? where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AttachmentTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AttachmentTable>? orderByList,
|
||||
AttachmentInclude? include,
|
||||
}) {
|
||||
return AttachmentIncludeList._(
|
||||
where: where,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy?.call(Attachment.t),
|
||||
orderByList: orderByList?.call(Attachment.t),
|
||||
include: include,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _isc.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _AttachmentImpl extends Attachment {
|
||||
_AttachmentImpl({
|
||||
_isc.UuidValue? id,
|
||||
int? spaceId,
|
||||
required String sha256,
|
||||
String? keyId,
|
||||
required _idt.ByteData payload,
|
||||
_idt.ByteData? nonce,
|
||||
}) : super._(
|
||||
id: id,
|
||||
spaceId: spaceId,
|
||||
sha256: sha256,
|
||||
keyId: keyId,
|
||||
payload: payload,
|
||||
nonce: nonce,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [Attachment]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
@override
|
||||
Attachment copyWith({
|
||||
Object? id = _Undefined,
|
||||
Object? spaceId = _Undefined,
|
||||
String? sha256,
|
||||
Object? keyId = _Undefined,
|
||||
_idt.ByteData? payload,
|
||||
Object? nonce = _Undefined,
|
||||
}) {
|
||||
return Attachment(
|
||||
id: id is _isc.UuidValue? ? id : this.id,
|
||||
spaceId: spaceId is int? ? spaceId : this.spaceId,
|
||||
sha256: sha256 ?? this.sha256,
|
||||
keyId: keyId is String? ? keyId : this.keyId,
|
||||
payload: payload ?? this.payload.clone(),
|
||||
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AttachmentUpdateTable extends _isd.UpdateTable<AttachmentTable> {
|
||||
AttachmentUpdateTable(super.table);
|
||||
|
||||
_isd.ColumnValue<int, int> spaceId(int? value) => _isd.ColumnValue(
|
||||
table.spaceId,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<String, String> sha256(String value) => _isd.ColumnValue(
|
||||
table.sha256,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<String, String> keyId(String? value) => _isd.ColumnValue(
|
||||
table.keyId,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> payload(_idt.ByteData value) =>
|
||||
_isd.ColumnValue(
|
||||
table.payload,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> nonce(_idt.ByteData? value) =>
|
||||
_isd.ColumnValue(
|
||||
table.nonce,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
class AttachmentTable extends _isd.Table<_isc.UuidValue?> {
|
||||
AttachmentTable({super.tableRelation}) : super(tableName: 'attachment') {
|
||||
updateTable = AttachmentUpdateTable(this);
|
||||
spaceId = _isd.ColumnInt(
|
||||
'spaceId',
|
||||
this,
|
||||
);
|
||||
sha256 = _isd.ColumnString(
|
||||
'sha256',
|
||||
this,
|
||||
);
|
||||
keyId = _isd.ColumnString(
|
||||
'keyId',
|
||||
this,
|
||||
);
|
||||
payload = _isd.ColumnByteData(
|
||||
'payload',
|
||||
this,
|
||||
);
|
||||
nonce = _isd.ColumnByteData(
|
||||
'nonce',
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
late final AttachmentUpdateTable updateTable;
|
||||
|
||||
/// The space owning this row. Maintained by the sync engine.
|
||||
late final _isd.ColumnInt spaceId;
|
||||
|
||||
/// The sealed file's SHA-256, in lowercase hex: its name in the store,
|
||||
/// which the server reads to serve it and to count and prune the files
|
||||
/// (`todo_files.dart`). The hash of sealed bytes says nothing of the file.
|
||||
late final _isd.ColumnString sha256;
|
||||
|
||||
/// The key it is sealed with (ADR 0002's envelope); never null in Todo,
|
||||
/// where everything is sealed.
|
||||
late final _isd.ColumnString keyId;
|
||||
|
||||
/// The content, sealed: UTF-8 JSON with a `schemaVersion` once opened.
|
||||
late final _isd.ColumnByteData payload;
|
||||
|
||||
/// The sealing's nonce (ADR 0002's envelope).
|
||||
late final _isd.ColumnByteData nonce;
|
||||
|
||||
@override
|
||||
List<_isd.Column> get columns => [
|
||||
id,
|
||||
spaceId,
|
||||
sha256,
|
||||
keyId,
|
||||
payload,
|
||||
nonce,
|
||||
];
|
||||
}
|
||||
|
||||
class AttachmentInclude extends _isd.IncludeObject {
|
||||
AttachmentInclude._();
|
||||
|
||||
@override
|
||||
Map<String, _isd.Include?> get includes => {};
|
||||
|
||||
@override
|
||||
_isd.Table<_isc.UuidValue?> get table => Attachment.t;
|
||||
}
|
||||
|
||||
class AttachmentIncludeList extends _isd.IncludeList {
|
||||
AttachmentIncludeList._({
|
||||
_isd.WhereExpressionBuilder<AttachmentTable>? where,
|
||||
super.limit,
|
||||
super.offset,
|
||||
super.orderBy,
|
||||
super.orderByList,
|
||||
super.include,
|
||||
}) {
|
||||
super.where = where?.call(Attachment.t);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, _isd.Include?> get includes => include?.includes ?? {};
|
||||
|
||||
@override
|
||||
_isd.Table<_isc.UuidValue?> get table => Attachment.t;
|
||||
}
|
||||
|
||||
class AttachmentRepository {
|
||||
const AttachmentRepository._();
|
||||
|
||||
/// Returns a list of [Attachment]s matching the given query parameters.
|
||||
///
|
||||
/// Use [where] to specify which items to include in the return value.
|
||||
/// If none is specified, all items will be returned.
|
||||
///
|
||||
/// To specify the order of the items use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// The maximum number of items can be set by [limit]. If no limit is set,
|
||||
/// all items matching the query will be returned.
|
||||
///
|
||||
/// [offset] defines how many items to skip, after which [limit] (or all)
|
||||
/// items are read from the database.
|
||||
///
|
||||
/// ```dart
|
||||
/// var persons = await Persons.db.find(
|
||||
/// session,
|
||||
/// where: (t) => t.lastName.equals('Jones'),
|
||||
/// orderBy: (t) => t.firstName,
|
||||
/// limit: 100,
|
||||
/// );
|
||||
/// ```
|
||||
Future<List<Attachment>> find(
|
||||
_isd.DatabaseSession session, {
|
||||
_isd.WhereExpressionBuilder<AttachmentTable>? where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AttachmentTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AttachmentTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
_isd.LockMode? lockMode,
|
||||
_isd.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.find<Attachment>(
|
||||
where: where?.call(Attachment.t),
|
||||
orderBy: orderBy?.call(Attachment.t),
|
||||
orderByList: orderByList?.call(Attachment.t),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the first matching [Attachment] matching the given query parameters.
|
||||
///
|
||||
/// Use [where] to specify which items to include in the return value.
|
||||
/// If none is specified, all items will be returned.
|
||||
///
|
||||
/// To specify the order use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// [offset] defines how many items to skip, after which the next one will be picked.
|
||||
///
|
||||
/// ```dart
|
||||
/// var youngestPerson = await Persons.db.findFirstRow(
|
||||
/// session,
|
||||
/// where: (t) => t.lastName.equals('Jones'),
|
||||
/// orderBy: (t) => t.age,
|
||||
/// );
|
||||
/// ```
|
||||
Future<Attachment?> findFirstRow(
|
||||
_isd.DatabaseSession session, {
|
||||
_isd.WhereExpressionBuilder<AttachmentTable>? where,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AttachmentTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AttachmentTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
_isd.LockMode? lockMode,
|
||||
_isd.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.findFirstRow<Attachment>(
|
||||
where: where?.call(Attachment.t),
|
||||
orderBy: orderBy?.call(Attachment.t),
|
||||
orderByList: orderByList?.call(Attachment.t),
|
||||
offset: offset,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Finds a single [Attachment] by its [id] or null if no such row exists.
|
||||
Future<Attachment?> findById(
|
||||
_isd.DatabaseSession session,
|
||||
_isc.UuidValue id, {
|
||||
_isd.Transaction? transaction,
|
||||
_isd.LockMode? lockMode,
|
||||
_isd.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.findById<Attachment>(
|
||||
id,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts all [Attachment]s in the list and returns the inserted rows.
|
||||
///
|
||||
/// The returned [Attachment]s will have their `id` fields set.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fails to
|
||||
/// insert, none of the rows will be inserted.
|
||||
///
|
||||
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
|
||||
/// rows are silently skipped, and only the successfully inserted rows are
|
||||
/// returned.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the inserted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<Attachment>> insert(
|
||||
_isd.DatabaseSession session,
|
||||
List<Attachment> rows, {
|
||||
_isd.Transaction? transaction,
|
||||
bool ignoreConflicts = false,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.insert<Attachment>(
|
||||
rows,
|
||||
transaction: transaction,
|
||||
ignoreConflicts: ignoreConflicts,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts a single [Attachment] and returns the inserted row.
|
||||
///
|
||||
/// The returned [Attachment] will have its `id` field set.
|
||||
Future<Attachment> insertRow(
|
||||
_isd.DatabaseSession session,
|
||||
Attachment row, {
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.insertRow<Attachment>(
|
||||
row,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upserts all [Attachment]s in the list and returns the resulting rows.
|
||||
///
|
||||
/// If a row conflicts on the given [conflictColumns], the existing row is
|
||||
/// updated with the new values. Otherwise, a new row is inserted.
|
||||
///
|
||||
/// If [updateColumns] is provided, only those columns will be updated on
|
||||
/// conflict. If null, all non-conflict, non-id columns are updated.
|
||||
///
|
||||
/// If [updateWhere] is provided, the update only applies to rows matching the
|
||||
/// given expression. Conflicting rows that don't match are skipped and not
|
||||
/// returned, so the resulting list may be shorter than [rows].
|
||||
///
|
||||
/// The returned [Attachment]s will have their `id` fields set.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fails,
|
||||
/// none of the rows will be affected.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the resulting rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<Attachment>> upsert(
|
||||
_isd.DatabaseSession session,
|
||||
List<Attachment> rows, {
|
||||
required _isd.ColumnSelections<AttachmentTable> conflictColumns,
|
||||
_isd.ColumnSelections<AttachmentTable>? updateColumns,
|
||||
_isd.WhereExpressionBuilder<AttachmentTable>? updateWhere,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.upsert<Attachment>(
|
||||
rows,
|
||||
conflictColumns: conflictColumns(Attachment.t),
|
||||
updateColumns: updateColumns?.call(Attachment.t),
|
||||
updateWhere: updateWhere?.call(Attachment.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upserts a single [Attachment] and returns the resulting row.
|
||||
///
|
||||
/// If the row conflicts on the given [conflictColumns], the existing row is
|
||||
/// updated. Otherwise, a new row is inserted.
|
||||
///
|
||||
/// If [updateColumns] is provided, only those columns will be updated on
|
||||
/// conflict. If null, all non-conflict, non-id columns are updated.
|
||||
///
|
||||
/// If [updateWhere] is provided, the update only applies when the existing
|
||||
/// row matches the expression. Returns `null` if no row was affected — for
|
||||
/// example when [updateWhere] does not match the conflicting row.
|
||||
///
|
||||
/// The returned [Attachment] will have its `id` field set.
|
||||
Future<Attachment?> upsertRow(
|
||||
_isd.DatabaseSession session,
|
||||
Attachment row, {
|
||||
required _isd.ColumnSelections<AttachmentTable> conflictColumns,
|
||||
_isd.ColumnSelections<AttachmentTable>? updateColumns,
|
||||
_isd.WhereExpressionBuilder<AttachmentTable>? updateWhere,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.upsertRow<Attachment>(
|
||||
row,
|
||||
conflictColumns: conflictColumns(Attachment.t),
|
||||
updateColumns: updateColumns?.call(Attachment.t),
|
||||
updateWhere: updateWhere?.call(Attachment.t),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates all [Attachment]s in the list and returns the updated rows. If
|
||||
/// [columns] is provided, only those columns will be updated. Defaults to
|
||||
/// all columns.
|
||||
/// This is an atomic operation, meaning that if one of the rows fails to
|
||||
/// update, none of the rows will be updated.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the updated rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<Attachment>> update(
|
||||
_isd.DatabaseSession session,
|
||||
List<Attachment> rows, {
|
||||
_isd.ColumnSelections<AttachmentTable>? columns,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.update<Attachment>(
|
||||
rows,
|
||||
columns: columns?.call(Attachment.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a single [Attachment]. The row needs to have its id set.
|
||||
/// Optionally, a list of [columns] can be provided to only update those
|
||||
/// columns. Defaults to all columns.
|
||||
Future<Attachment> updateRow(
|
||||
_isd.DatabaseSession session,
|
||||
Attachment row, {
|
||||
_isd.ColumnSelections<AttachmentTable>? columns,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.updateRow<Attachment>(
|
||||
row,
|
||||
columns: columns?.call(Attachment.t),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a single [Attachment] by its [id] with the specified [columnValues].
|
||||
/// Returns the updated row or null if no row with the given id exists.
|
||||
Future<Attachment?> updateById(
|
||||
_isd.DatabaseSession session,
|
||||
_isc.UuidValue id, {
|
||||
required _isd.ColumnValueListBuilder<AttachmentUpdateTable> columnValues,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.updateById<Attachment>(
|
||||
id,
|
||||
columnValues: columnValues(Attachment.t.updateTable),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates all [Attachment]s matching the [where] expression with the specified [columnValues].
|
||||
/// Returns the list of updated rows.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the updated rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<Attachment>> updateWhere(
|
||||
_isd.DatabaseSession session, {
|
||||
required _isd.ColumnValueListBuilder<AttachmentUpdateTable> columnValues,
|
||||
required _isd.WhereExpressionBuilder<AttachmentTable> where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AttachmentTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AttachmentTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.updateWhere<Attachment>(
|
||||
columnValues: columnValues(Attachment.t.updateTable),
|
||||
where: where(Attachment.t),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy?.call(Attachment.t),
|
||||
orderByList: orderByList?.call(Attachment.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes all [Attachment]s in the list and returns the deleted rows.
|
||||
///
|
||||
/// To specify the order of the returned rows use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fail to
|
||||
/// be deleted, none of the rows will be deleted.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the deleted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<Attachment>> delete(
|
||||
_isd.DatabaseSession session,
|
||||
List<Attachment> rows, {
|
||||
_isd.OrderByBuilder<AttachmentTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AttachmentTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.delete<Attachment>(
|
||||
rows,
|
||||
orderBy: orderBy?.call(Attachment.t),
|
||||
orderByList: orderByList?.call(Attachment.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes a single [Attachment].
|
||||
Future<Attachment> deleteRow(
|
||||
_isd.DatabaseSession session,
|
||||
Attachment row, {
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.deleteRow<Attachment>(
|
||||
row,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes all rows matching the [where] expression.
|
||||
///
|
||||
/// To specify the order of the returned rows use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the deleted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<Attachment>> deleteWhere(
|
||||
_isd.DatabaseSession session, {
|
||||
required _isd.WhereExpressionBuilder<AttachmentTable> where,
|
||||
_isd.OrderByBuilder<AttachmentTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AttachmentTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.deleteWhere<Attachment>(
|
||||
where: where(Attachment.t),
|
||||
orderBy: orderBy?.call(Attachment.t),
|
||||
orderByList: orderByList?.call(Attachment.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Counts the number of rows matching the [where] expression. If omitted,
|
||||
/// will return the count of all rows in the table.
|
||||
Future<int> count(
|
||||
_isd.DatabaseSession session, {
|
||||
_isd.WhereExpressionBuilder<AttachmentTable>? where,
|
||||
int? limit,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.count<Attachment>(
|
||||
where: where?.call(Attachment.t),
|
||||
limit: limit,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Acquires row-level locks on [Attachment] rows matching the [where] expression.
|
||||
Future<void> lockRows(
|
||||
_isd.DatabaseSession session, {
|
||||
required _isd.WhereExpressionBuilder<AttachmentTable> where,
|
||||
required _isd.LockMode lockMode,
|
||||
required _isd.Transaction transaction,
|
||||
_isd.LockBehavior lockBehavior = _isd.LockBehavior.wait,
|
||||
}) async {
|
||||
return session.db.lockRows<Attachment>(
|
||||
where: where(Attachment.t),
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
|||
import 'package:serverpod_database/serverpod_database.dart' as _isd;
|
||||
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart'
|
||||
as _ipulbpi2;
|
||||
import 'attachment/attachment.dart' as _imhw6pbz;
|
||||
import 'comment/task_comment.dart' as _i68ws957;
|
||||
import 'filter/saved_filter.dart' as _i9q9fhid;
|
||||
import 'label/label.dart' as _ivak08z4;
|
||||
|
|
@ -39,6 +40,7 @@ import 'settings/todo_settings.dart' as _icak60vd;
|
|||
import 'task/task.dart' as _ib4f8uyw;
|
||||
import 'weather/forecast_hour.dart' as _i1l0g3o2;
|
||||
import 'weather/weather_refused.dart' as _ieu1vbq0;
|
||||
export 'attachment/attachment.dart';
|
||||
export 'comment/task_comment.dart';
|
||||
export 'filter/saved_filter.dart';
|
||||
export 'label/label.dart';
|
||||
|
|
@ -64,6 +66,96 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
static final Protocol _instance = Protocol._().._registerHostProtocols();
|
||||
|
||||
static List<_isd.TableDefinition> get targetTableDefinitions => [
|
||||
_isd.TableDefinition(
|
||||
name: 'attachment',
|
||||
dartName: 'Attachment',
|
||||
schema: 'public',
|
||||
module: 'halkyon_todo',
|
||||
columns: [
|
||||
_isd.ColumnDefinition(
|
||||
name: 'id',
|
||||
columnType: _isd.ColumnType.uuid,
|
||||
isNullable: false,
|
||||
dartType: 'UuidValue?',
|
||||
columnDefault: 'random_v7',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'spaceId',
|
||||
columnType: _isd.ColumnType.bigint,
|
||||
isNullable: true,
|
||||
dartType: 'int?',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'sha256',
|
||||
columnType: _isd.ColumnType.text,
|
||||
isNullable: false,
|
||||
dartType: 'String',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'keyId',
|
||||
columnType: _isd.ColumnType.text,
|
||||
isNullable: true,
|
||||
dartType: 'String?',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'payload',
|
||||
columnType: _isd.ColumnType.bytea,
|
||||
isNullable: false,
|
||||
dartType: 'dart:typed_data:ByteData',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'nonce',
|
||||
columnType: _isd.ColumnType.bytea,
|
||||
isNullable: true,
|
||||
dartType: 'dart:typed_data:ByteData?',
|
||||
),
|
||||
],
|
||||
foreignKeys: [
|
||||
_isd.ForeignKeyDefinition(
|
||||
constraintName: 'attachment_fk_0',
|
||||
columns: ['spaceId'],
|
||||
referenceTable: 'offline_sync_spaces',
|
||||
referenceTableSchema: 'public',
|
||||
referenceColumns: ['id'],
|
||||
onUpdate: _isd.ForeignKeyAction.noAction,
|
||||
onDelete: _isd.ForeignKeyAction.cascade,
|
||||
matchType: null,
|
||||
),
|
||||
],
|
||||
indexes: [
|
||||
_isd.IndexDefinition(
|
||||
indexName: 'attachment_space_idx',
|
||||
tableSpace: null,
|
||||
elements: [
|
||||
_isd.IndexElementDefinition(
|
||||
type: _isd.IndexElementDefinitionType.column,
|
||||
definition: 'spaceId',
|
||||
),
|
||||
],
|
||||
type: 'btree',
|
||||
isUnique: false,
|
||||
isPrimary: false,
|
||||
),
|
||||
_isd.IndexDefinition(
|
||||
indexName: 'attachment_hash_idx',
|
||||
tableSpace: null,
|
||||
elements: [
|
||||
_isd.IndexElementDefinition(
|
||||
type: _isd.IndexElementDefinitionType.column,
|
||||
definition: 'spaceId',
|
||||
),
|
||||
_isd.IndexElementDefinition(
|
||||
type: _isd.IndexElementDefinitionType.column,
|
||||
definition: 'sha256',
|
||||
),
|
||||
],
|
||||
type: 'btree',
|
||||
isUnique: false,
|
||||
isPrimary: false,
|
||||
),
|
||||
],
|
||||
managed: true,
|
||||
),
|
||||
_isd.TableDefinition(
|
||||
name: 'content_key',
|
||||
dartName: 'ContentKey',
|
||||
|
|
@ -643,6 +735,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
}
|
||||
}
|
||||
|
||||
if (t == _imhw6pbz.Attachment) {
|
||||
return _imhw6pbz.Attachment.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i68ws957.TaskComment) {
|
||||
return _i68ws957.TaskComment.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -685,6 +780,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _ieu1vbq0.WeatherRefused) {
|
||||
return _ieu1vbq0.WeatherRefused.fromJson(data) as T;
|
||||
}
|
||||
if (t == _isc.getType<_imhw6pbz.Attachment?>()) {
|
||||
return (data != null ? _imhw6pbz.Attachment.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _isc.getType<_i68ws957.TaskComment?>()) {
|
||||
return (data != null ? _i68ws957.TaskComment.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -763,6 +861,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
|
||||
static String? getClassNameForType(Type type) {
|
||||
return switch (type) {
|
||||
_imhw6pbz.Attachment => 'Attachment',
|
||||
_i68ws957.TaskComment => 'TaskComment',
|
||||
_i9q9fhid.SavedFilter => 'SavedFilter',
|
||||
_ivak08z4.Label => 'Label',
|
||||
|
|
@ -794,6 +893,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
}
|
||||
|
||||
switch (data) {
|
||||
case _imhw6pbz.Attachment():
|
||||
return 'Attachment';
|
||||
case _i68ws957.TaskComment():
|
||||
return 'TaskComment';
|
||||
case _i9q9fhid.SavedFilter():
|
||||
|
|
@ -858,6 +959,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (dataClassName is! String) {
|
||||
return super.deserializeByClassName(data);
|
||||
}
|
||||
if (dataClassName == 'Attachment') {
|
||||
return deserialize<_imhw6pbz.Attachment>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'TaskComment') {
|
||||
return deserialize<_i68ws957.TaskComment>(data['data']);
|
||||
}
|
||||
|
|
@ -979,6 +1083,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
}
|
||||
}
|
||||
switch (t) {
|
||||
case _imhw6pbz.Attachment:
|
||||
return _imhw6pbz.Attachment.t;
|
||||
case _i68ws957.TaskComment:
|
||||
return _i68ws957.TaskComment.t;
|
||||
case _i9q9fhid.SavedFilter:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod_database/serverpod_database.dart' as _isd;
|
||||
import 'attachment/attachment.dart' as _imhw6pbz;
|
||||
import 'comment/task_comment.dart' as _i68ws957;
|
||||
import 'filter/saved_filter.dart' as _i9q9fhid;
|
||||
import 'label/label.dart' as _ivak08z4;
|
||||
|
|
@ -24,6 +25,7 @@ import 'task/task.dart' as _ib4f8uyw;
|
|||
/// `serverpod_offline_sync` package, including the ones owned by modules and
|
||||
/// shared packages.
|
||||
final List<_isd.Table> syncTables = [
|
||||
_imhw6pbz.Attachment.t,
|
||||
_imed3x5d.ContentKey.t,
|
||||
_ivak08z4.Label.t,
|
||||
_i9c44spp.Project.t,
|
||||
|
|
|
|||
|
|
@ -439,5 +439,17 @@
|
|||
"weatherPlaceHelp": "Die Tage zeigen die Vorhersage für diesen Ort, und Aufgaben, die vom Wetter abhängen, richten sich nach ihr. Der Standort deines Geräts wird dafür nie verwendet.",
|
||||
"weatherPlaceRemove": "Ort entfernen",
|
||||
"weatherOff": "Diese Instanz liefert keine Vorhersagen.",
|
||||
"weatherBy": "Wetterdaten von Open-Meteo.com"
|
||||
"weatherBy": "Wetterdaten von Open-Meteo.com",
|
||||
"attachmentAdd": "Dateien anhängen",
|
||||
"attachmentAnyFile": "Alle Dateien",
|
||||
"attachmentTooLarge": "{name} ist größer als 100 MB.",
|
||||
"attachmentNotAdded": "{name} wurde nicht angehängt.",
|
||||
"attachmentDownload": "Herunterladen",
|
||||
"attachmentUnavailable": "Die Datei lässt sich gerade nicht laden. Bist du online?",
|
||||
"attachmentSave": "Kopie speichern",
|
||||
"attachmentRemove": "Entfernen",
|
||||
"attachmentRemoveTitle": "Diese Datei entfernen?",
|
||||
"attachmentMenu": "Dateioptionen",
|
||||
"attachmentSizeKb": "{size} KB",
|
||||
"attachmentSizeMb": "{size} MB"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1440,5 +1440,29 @@
|
|||
"weatherBy": "Weather data by Open-Meteo.com",
|
||||
"@weatherBy": {
|
||||
"description": "The credit Open-Meteo's licence asks for next to its data (D40)."
|
||||
}
|
||||
},
|
||||
"attachmentAdd": "Attach files",
|
||||
"@attachmentAdd": {"description": "The paperclip button adding files to a task's description or a comment."},
|
||||
"attachmentAnyFile": "Any file",
|
||||
"@attachmentAnyFile": {"description": "The file type offered in the dialog picking files to attach."},
|
||||
"attachmentTooLarge": "{name} is larger than 100 MB.",
|
||||
"@attachmentTooLarge": {"description": "Said when a file picked to attach is too large.", "placeholders": {"name": {"type": "String"}}},
|
||||
"attachmentNotAdded": "{name} wasn't attached.",
|
||||
"@attachmentNotAdded": {"description": "Said when a file couldn't be attached (no keys on this device, or it failed).", "placeholders": {"name": {"type": "String"}}},
|
||||
"attachmentDownload": "Download",
|
||||
"@attachmentDownload": {"description": "The tooltip of an attachment not on this device yet, fetched when tapped."},
|
||||
"attachmentUnavailable": "This file can't be fetched right now. Are you online?",
|
||||
"@attachmentUnavailable": {"description": "Said when an attachment couldn't be downloaded."},
|
||||
"attachmentSave": "Save a copy",
|
||||
"@attachmentSave": {"description": "The menu entry saving an attachment where the person chooses."},
|
||||
"attachmentRemove": "Remove",
|
||||
"@attachmentRemove": {"description": "The menu entry removing an attachment, and the button removing a file picked for a comment before it is sent."},
|
||||
"attachmentRemoveTitle": "Remove this file?",
|
||||
"@attachmentRemoveTitle": {"description": "The title of the question before an attachment is removed."},
|
||||
"attachmentMenu": "File options",
|
||||
"@attachmentMenu": {"description": "The tooltip of an attachment's menu."},
|
||||
"attachmentSizeKb": "{size} KB",
|
||||
"@attachmentSizeKb": {"description": "An attachment's size in kilobytes.", "placeholders": {"size": {"type": "int"}}},
|
||||
"attachmentSizeMb": "{size} MB",
|
||||
"@attachmentSizeMb": {"description": "An attachment's size in megabytes, with one decimal.", "placeholders": {"size": {"type": "String"}}}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2739,6 +2739,78 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Weather data by Open-Meteo.com'**
|
||||
String get weatherBy;
|
||||
|
||||
/// The paperclip button adding files to a task's description or a comment.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Attach files'**
|
||||
String get attachmentAdd;
|
||||
|
||||
/// The file type offered in the dialog picking files to attach.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Any file'**
|
||||
String get attachmentAnyFile;
|
||||
|
||||
/// Said when a file picked to attach is too large.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{name} is larger than 100 MB.'**
|
||||
String attachmentTooLarge(String name);
|
||||
|
||||
/// Said when a file couldn't be attached (no keys on this device, or it failed).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{name} wasn\'t attached.'**
|
||||
String attachmentNotAdded(String name);
|
||||
|
||||
/// The tooltip of an attachment not on this device yet, fetched when tapped.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download'**
|
||||
String get attachmentDownload;
|
||||
|
||||
/// Said when an attachment couldn't be downloaded.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This file can\'t be fetched right now. Are you online?'**
|
||||
String get attachmentUnavailable;
|
||||
|
||||
/// The menu entry saving an attachment where the person chooses.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save a copy'**
|
||||
String get attachmentSave;
|
||||
|
||||
/// The menu entry removing an attachment, and the button removing a file picked for a comment before it is sent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove'**
|
||||
String get attachmentRemove;
|
||||
|
||||
/// The title of the question before an attachment is removed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove this file?'**
|
||||
String get attachmentRemoveTitle;
|
||||
|
||||
/// The tooltip of an attachment's menu.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'File options'**
|
||||
String get attachmentMenu;
|
||||
|
||||
/// An attachment's size in kilobytes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{size} KB'**
|
||||
String attachmentSizeKb(int size);
|
||||
|
||||
/// An attachment's size in megabytes, with one decimal.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{size} MB'**
|
||||
String attachmentSizeMb(String size);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -1739,4 +1739,49 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get weatherBy => 'Wetterdaten von Open-Meteo.com';
|
||||
|
||||
@override
|
||||
String get attachmentAdd => 'Dateien anhängen';
|
||||
|
||||
@override
|
||||
String get attachmentAnyFile => 'Alle Dateien';
|
||||
|
||||
@override
|
||||
String attachmentTooLarge(String name) {
|
||||
return '$name ist größer als 100 MB.';
|
||||
}
|
||||
|
||||
@override
|
||||
String attachmentNotAdded(String name) {
|
||||
return '$name wurde nicht angehängt.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get attachmentDownload => 'Herunterladen';
|
||||
|
||||
@override
|
||||
String get attachmentUnavailable =>
|
||||
'Die Datei lässt sich gerade nicht laden. Bist du online?';
|
||||
|
||||
@override
|
||||
String get attachmentSave => 'Kopie speichern';
|
||||
|
||||
@override
|
||||
String get attachmentRemove => 'Entfernen';
|
||||
|
||||
@override
|
||||
String get attachmentRemoveTitle => 'Diese Datei entfernen?';
|
||||
|
||||
@override
|
||||
String get attachmentMenu => 'Dateioptionen';
|
||||
|
||||
@override
|
||||
String attachmentSizeKb(int size) {
|
||||
return '$size KB';
|
||||
}
|
||||
|
||||
@override
|
||||
String attachmentSizeMb(String size) {
|
||||
return '$size MB';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1735,6 +1735,51 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get weatherBy => 'Weather data by Open-Meteo.com';
|
||||
|
||||
@override
|
||||
String get attachmentAdd => 'Attach files';
|
||||
|
||||
@override
|
||||
String get attachmentAnyFile => 'Any file';
|
||||
|
||||
@override
|
||||
String attachmentTooLarge(String name) {
|
||||
return '$name is larger than 100 MB.';
|
||||
}
|
||||
|
||||
@override
|
||||
String attachmentNotAdded(String name) {
|
||||
return '$name wasn\'t attached.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get attachmentDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get attachmentUnavailable =>
|
||||
'This file can\'t be fetched right now. Are you online?';
|
||||
|
||||
@override
|
||||
String get attachmentSave => 'Save a copy';
|
||||
|
||||
@override
|
||||
String get attachmentRemove => 'Remove';
|
||||
|
||||
@override
|
||||
String get attachmentRemoveTitle => 'Remove this file?';
|
||||
|
||||
@override
|
||||
String get attachmentMenu => 'File options';
|
||||
|
||||
@override
|
||||
String attachmentSizeKb(int size) {
|
||||
return '$size KB';
|
||||
}
|
||||
|
||||
@override
|
||||
String attachmentSizeMb(String size) {
|
||||
return '$size MB';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import 'package:halkyon_todo_client/halkyon_todo_client.dart'
|
|||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/attachment/data/attachment_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/attachment/data/sync_attachment_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/calendar/data/calendar_feeds.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/comment/data/comment_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/comment/data/sync_comment_gateway.dart';
|
||||
|
|
@ -76,6 +78,10 @@ class TodoPerson(
|
|||
/// Their comments on tasks, sealed and synced (D36).
|
||||
required final CommentRepository comments,
|
||||
|
||||
/// The files attached to their tasks and comments, sealed and synced
|
||||
/// (Halkyon's ADR 0043).
|
||||
required final AttachmentRepository attachments,
|
||||
|
||||
/// Their settings of Todo's (Upcoming's Display), sealed and synced.
|
||||
required final SettingsRepository settings,
|
||||
|
||||
|
|
@ -212,9 +218,14 @@ class TodoDependencies({
|
|||
/// recovery codes).
|
||||
@override required final FileSaver fileSaver,
|
||||
|
||||
/// Lets the person pick a file of the device (their profile's picture).
|
||||
/// Lets the person pick a file of the device (their profile's picture,
|
||||
/// files to attach).
|
||||
@override required final FilePicker files,
|
||||
|
||||
/// Opens an attached file in the app the system picks (Halkyon's ADR
|
||||
/// 0043, D1).
|
||||
required final FileOpener opener,
|
||||
|
||||
/// People's public profiles on the instance, the person's own among them:
|
||||
/// one for every Halkyon app (decided 2026-11-02).
|
||||
@override required final PublicProfileRepository profiles,
|
||||
|
|
@ -339,6 +350,7 @@ class TodoDependencies({
|
|||
assistantOperators: assistantOperators,
|
||||
fileSaver: PlatformFileSaver(),
|
||||
files: FileSelectorFilePicker(),
|
||||
opener: SystemFileOpener(await getTemporaryDirectory()),
|
||||
profiles: PublicProfileRepository(
|
||||
BasePublicProfileGateway(client.modules.base),
|
||||
),
|
||||
|
|
@ -452,11 +464,27 @@ Future<TodoPerson> _openPersonData(
|
|||
final projects = ProjectRepository(SyncProjectGateway(db, sync), keys);
|
||||
final sections = SectionRepository(SyncSectionGateway(db, sync), keys);
|
||||
final settings = SettingsRepository(SyncSettingsGateway(db, sync), keys);
|
||||
final comments = CommentRepository(SyncCommentGateway(db, sync), keys);
|
||||
final attachments = AttachmentRepository(
|
||||
SyncAttachmentGateway(db, sync),
|
||||
keys,
|
||||
ServerpodFileServerGateway(client.modules.base),
|
||||
DirectoryFileCache(Directory(p.join(folder.path, 'files'))),
|
||||
uploadTriggers: sync.synced,
|
||||
filesAllowed: () => sync.filesAllowed,
|
||||
);
|
||||
final comments = CommentRepository(
|
||||
SyncCommentGateway(db, sync),
|
||||
keys,
|
||||
onDeleted: attachments.deleteOnComments,
|
||||
);
|
||||
final tasks = TaskRepository(
|
||||
SyncTaskGateway(db, sync),
|
||||
keys,
|
||||
onDeleted: comments.deleteOn,
|
||||
// A task's comments go with it, and the files attached to it and them.
|
||||
onDeleted: (ids) async {
|
||||
await comments.deleteOn(ids);
|
||||
await attachments.deleteOnTasks(ids);
|
||||
},
|
||||
automaticReminder: () async => (await settings.read()).reminders.automatic,
|
||||
);
|
||||
final labels = LabelRepository(SyncLabelGateway(db, sync), keys);
|
||||
|
|
@ -479,6 +507,7 @@ Future<TodoPerson> _openPersonData(
|
|||
labels: labels,
|
||||
filters: filters,
|
||||
comments: comments,
|
||||
attachments: attachments,
|
||||
settings: settings,
|
||||
reminders: reminders,
|
||||
notifications: notifications,
|
||||
|
|
@ -501,6 +530,7 @@ Future<TodoPerson> _openPersonData(
|
|||
await labels.dispose();
|
||||
await filters.dispose();
|
||||
await comments.dispose();
|
||||
await attachments.dispose();
|
||||
await settings.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// An attachment's row as kept: its id, the hash of its sealed file, which
|
||||
/// the server reads, and its envelope, sealed (ADR 0033, D5).
|
||||
typedef AttachmentRow = ({String id, String sha256, Envelope envelope});
|
||||
|
||||
/// Where the person's attachment rows are kept. `AttachmentRepository`
|
||||
/// seals and opens; this only keeps. Rows are never changed, only added and
|
||||
/// deleted.
|
||||
abstract interface class AttachmentGateway() {
|
||||
/// Every row.
|
||||
Future<List<AttachmentRow>> rows();
|
||||
|
||||
Future<void> insert(AttachmentRow row);
|
||||
|
||||
/// Deletes the rows [ids] together, each payload blanked first (ADR 0002,
|
||||
/// rule 2).
|
||||
Future<void> delete(Iterable<String> ids);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/attachment/model/attachment.dart';
|
||||
|
||||
/// The schema of an attachment's payload this app writes and reads.
|
||||
const attachmentSchemaVersion = 1;
|
||||
|
||||
/// [content] as its row's payload, before sealing.
|
||||
ByteData encodeAttachment(AttachmentContent content) =>
|
||||
encodeJson(attachmentSchemaVersion, {
|
||||
'taskId': content.taskId,
|
||||
'commentId': ?content.commentId,
|
||||
'name': content.name,
|
||||
'mimeType': content.mimeType,
|
||||
'byteSize': content.byteSize,
|
||||
'sha256': content.sha256,
|
||||
'addedAt': content.addedAt.toUtc().toIso8601String(),
|
||||
});
|
||||
|
||||
/// The attachment [payload] holds, opened; null for one it can't read.
|
||||
AttachmentContent? decodeAttachment(ByteData payload) => decodeJson(
|
||||
payload,
|
||||
version: attachmentSchemaVersion,
|
||||
name: 'Attachment',
|
||||
read: (json) => AttachmentContent(
|
||||
taskId: json['taskId']! as String,
|
||||
commentId: json['commentId'] as String?,
|
||||
name: json['name']! as String,
|
||||
mimeType: json['mimeType']! as String,
|
||||
byteSize: json['byteSize']! as int,
|
||||
sha256: json['sha256']! as String,
|
||||
addedAt: DateTime.parse(json['addedAt']! as String),
|
||||
),
|
||||
);
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_core/halkyon_core.dart' show CoalescingRunner;
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' show Uuid;
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/attachment/data/attachment_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/attachment/data/attachment_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/attachment/model/attachment.dart';
|
||||
|
||||
/// A file the person picked, before it is attached.
|
||||
typedef PickedAttachment = ({String name, String mimeType, Uint8List bytes});
|
||||
|
||||
/// The files attached to the person's tasks and comments (Halkyon's ADR
|
||||
/// 0043): an `attachment` row each, sealed as every row of Todo's (ADR
|
||||
/// 0033, D5), and the file itself sealed for its row (halkyon_app's
|
||||
/// [SealedFile]), so the instance sees neither, kept on this device and in
|
||||
/// halkyon_base's store by the sealed file's hash.
|
||||
///
|
||||
/// A file added here is kept on this device, sealed and opened, and waits
|
||||
/// to be uploaded; the uploads run whenever `uploadTriggers` fires (after
|
||||
/// each sync, so the row is usually on the server before its file), one at
|
||||
/// a time, and what fails waits for the next time. A file this device lacks
|
||||
/// is downloaded when shown (D5), checked against its hashes and kept. On
|
||||
/// mobile data, files wait while sync holds them back (ADR 0042).
|
||||
class AttachmentRepository(
|
||||
final AttachmentGateway _gateway,
|
||||
final TodoKeys _keys,
|
||||
final FileServerGateway _server,
|
||||
final FileCache _files, {
|
||||
Stream<void> uploadTriggers = const Stream.empty(),
|
||||
|
||||
/// Whether files may go up and come down now (ADR 0042); always where not
|
||||
/// said.
|
||||
final bool Function() _filesAllowed = _always,
|
||||
}) {
|
||||
this {
|
||||
_triggers = uploadTriggers.listen((_) => unawaited(uploadPending()));
|
||||
}
|
||||
|
||||
/// The largest file attached: 100 MB, as the server takes (D3).
|
||||
static const maxBytes = 100 * 1024 * 1024;
|
||||
|
||||
/// The table every payload is sealed for.
|
||||
static const _table = 'attachment';
|
||||
|
||||
late final StreamSubscription<void> _triggers;
|
||||
StreamSubscription<void>? _remote;
|
||||
|
||||
/// This device's changes, and those a sync brings while anyone listens.
|
||||
late final StreamController<void> _changes = StreamController.broadcast(
|
||||
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
|
||||
onCancel: () => _remote?.cancel(),
|
||||
);
|
||||
|
||||
late final _envelope = PayloadEnvelope(_keys.cipher);
|
||||
|
||||
/// The downloads under way, by hash, so a file shown twice at once is
|
||||
/// downloaded once.
|
||||
final _downloads = <String, Future<Uint8List?>>{};
|
||||
|
||||
/// Uploads what waits, one round at a time.
|
||||
late final _uploads = CoalescingRunner(
|
||||
_uploadAll,
|
||||
onError: (error, _) =>
|
||||
log('Uploading files failed', name: 'Attachments', error: error),
|
||||
);
|
||||
|
||||
var _stopped = false;
|
||||
|
||||
/// Fires when the attachments changed, here or on another device.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
/// Every attachment, oldest first.
|
||||
Future<List<StoredAttachment>> all() async {
|
||||
await _keys.refresh();
|
||||
final rows = await _gateway.rows()
|
||||
..sort((a, b) => a.id.compareTo(b.id));
|
||||
return [
|
||||
for (final row in rows) StoredAttachment(row.id, row.sha256, _open(row)),
|
||||
];
|
||||
}
|
||||
|
||||
/// Attaches [file] to the task [taskId], or to its comment [commentId],
|
||||
/// and returns the attachment's ID. Throws an [ArgumentError] for an empty
|
||||
/// file or one over [maxBytes], and [KeysMissing] where this device can't
|
||||
/// seal.
|
||||
Future<String> add(
|
||||
PickedAttachment file, {
|
||||
required String taskId,
|
||||
String? commentId,
|
||||
}) async {
|
||||
final bytes = file.bytes;
|
||||
if (bytes.isEmpty || bytes.length > maxBytes) {
|
||||
throw ArgumentError.value(bytes.length, 'bytes', 'is out of range');
|
||||
}
|
||||
final keyId = await _keys.keyToWrite();
|
||||
final id = const Uuid().v7();
|
||||
final plain = sha256.convert(bytes).toString();
|
||||
final sealed = SealedFile.seal(
|
||||
_keys.cipher,
|
||||
bytes,
|
||||
keyId: keyId,
|
||||
context: _fileContext(id),
|
||||
);
|
||||
final stored = sha256.convert(sealed).toString();
|
||||
await _files.write(plain, bytes);
|
||||
await _files.write(stored, sealed);
|
||||
await _gateway.insert((
|
||||
id: id,
|
||||
sha256: stored,
|
||||
envelope: _envelope.seal(
|
||||
encodeAttachment(
|
||||
AttachmentContent(
|
||||
taskId: taskId,
|
||||
commentId: commentId,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
byteSize: bytes.length,
|
||||
sha256: plain,
|
||||
addedAt: clock.now(),
|
||||
),
|
||||
),
|
||||
keyId: keyId,
|
||||
table: _table,
|
||||
id: id,
|
||||
),
|
||||
));
|
||||
await _files.markPending(stored);
|
||||
_changes.add(null);
|
||||
unawaited(uploadPending());
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Whether this device has [attachment]'s file, opened, so it shows
|
||||
/// without asking the server.
|
||||
Future<bool> isHere(StoredAttachment attachment) async =>
|
||||
switch (attachment.content) {
|
||||
final content? => await _files.read(content.sha256) != null,
|
||||
null => false,
|
||||
};
|
||||
|
||||
/// Where this device keeps [attachment]'s file, opened, once it has it.
|
||||
String? pathOf(StoredAttachment attachment) => switch (attachment.content) {
|
||||
final content? => _files.pathOf(content.sha256),
|
||||
null => null,
|
||||
};
|
||||
|
||||
/// [attachment]'s file, opened: as this device keeps it, else its sealed
|
||||
/// file (kept, or downloaded), checked against the row's hash, opened for
|
||||
/// its row and checked against the hash of what was sealed. Null when it
|
||||
/// can't be had now: it doesn't open, no connection, files held back on
|
||||
/// mobile data (but where the person [asked] for it), or not uploaded yet.
|
||||
Future<Uint8List?> fileOf(
|
||||
StoredAttachment attachment, {
|
||||
bool asked = false,
|
||||
}) async {
|
||||
final content = attachment.content;
|
||||
if (content == null || !isSha256(attachment.storedSha256)) {
|
||||
return null;
|
||||
}
|
||||
if (await _files.read(content.sha256) case final bytes?) {
|
||||
return bytes;
|
||||
}
|
||||
final stored = attachment.storedSha256;
|
||||
final sealed =
|
||||
await _files.read(stored) ??
|
||||
await (_downloads[stored] ??=
|
||||
_download(
|
||||
attachment.id,
|
||||
stored,
|
||||
asked: asked,
|
||||
).whenComplete(() {
|
||||
// A block: the future removed is this one.
|
||||
_downloads.remove(stored);
|
||||
}));
|
||||
final opened = sealed == null
|
||||
? null
|
||||
: SealedFile.open(
|
||||
_keys.cipher,
|
||||
sealed,
|
||||
context: _fileContext(attachment.id),
|
||||
);
|
||||
if (opened == null || sha256.convert(opened).toString() != content.sha256) {
|
||||
return null;
|
||||
}
|
||||
await _files.write(content.sha256, opened);
|
||||
return opened;
|
||||
}
|
||||
|
||||
Future<Uint8List?> _download(
|
||||
String id,
|
||||
String hash, {
|
||||
required bool asked,
|
||||
}) async {
|
||||
if (!asked && !_filesAllowed()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final bytes = await _server.download(id);
|
||||
if (bytes == null || sha256.convert(bytes).toString() != hash) {
|
||||
return null;
|
||||
}
|
||||
await _files.write(hash, bytes);
|
||||
return bytes;
|
||||
} on Object catch (error) {
|
||||
log('Downloading a file failed', name: 'Attachments', error: error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads the files that wait; what fails waits for the next time.
|
||||
Future<void> uploadPending() => _uploads.run();
|
||||
|
||||
Future<void> _uploadAll() async {
|
||||
if (_stopped || !_filesAllowed()) {
|
||||
return;
|
||||
}
|
||||
final live = {for (final row in await _gateway.rows()) row.sha256};
|
||||
for (final hash in await _files.pendingUploads()) {
|
||||
try {
|
||||
if (!live.contains(hash) || await _upload(hash)) {
|
||||
// Uploaded, or no live row names it any more: owed no longer.
|
||||
await _files.clearPending(hash);
|
||||
}
|
||||
} on FileLimitReached catch (error) {
|
||||
// The others wouldn't fit either: the next round asks again.
|
||||
log('No room for more files', name: 'Attachments', error: error);
|
||||
return;
|
||||
} on Object catch (error) {
|
||||
// Without a connection the others fail too: next time.
|
||||
log('Uploading a file failed', name: 'Attachments', error: error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads the sealed file with [hash]; whether it no longer waits.
|
||||
Future<bool> _upload(String hash) async {
|
||||
final bytes = await _files.read(hash);
|
||||
if (bytes == null) {
|
||||
return true;
|
||||
}
|
||||
final description = await _server.startUpload(
|
||||
hash,
|
||||
bytes.length,
|
||||
encrypted: true,
|
||||
);
|
||||
if (description == null) {
|
||||
return true;
|
||||
}
|
||||
return await _server.upload(description, bytes) &&
|
||||
await _server.finishUpload(hash, encrypted: true);
|
||||
}
|
||||
|
||||
/// How much room the person's files take on the server, and may take;
|
||||
/// null when that can't be asked now.
|
||||
Future<FileRoom?> room() async {
|
||||
try {
|
||||
return await _server.room();
|
||||
} on Exception catch (error) {
|
||||
log('Asking for the room failed', name: 'Attachments', error: error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the attachments [ids]: their files go from the server once no
|
||||
/// row names them, and stop taking the person's room.
|
||||
Future<void> delete(Iterable<String> ids) async {
|
||||
final gone = ids.toSet();
|
||||
if (gone.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final rows = await _gateway.rows();
|
||||
await _gateway.delete(gone);
|
||||
for (final row in rows) {
|
||||
if (gone.contains(row.id)) {
|
||||
// Its upload, if still waiting, is no longer owed.
|
||||
await _files.clearPending(row.sha256);
|
||||
}
|
||||
}
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Deletes the attachments of the tasks [taskIds], which are gone, with
|
||||
/// their comments'.
|
||||
Future<void> deleteOnTasks(Set<String> taskIds) async => await delete([
|
||||
for (final attachment in await all())
|
||||
if (taskIds.contains(attachment.content?.taskId)) attachment.id,
|
||||
]);
|
||||
|
||||
/// Deletes the attachments of the comments [commentIds], which are gone.
|
||||
Future<void> deleteOnComments(Set<String> commentIds) async => await delete([
|
||||
for (final attachment in await all())
|
||||
if (commentIds.contains(attachment.content?.commentId)) attachment.id,
|
||||
]);
|
||||
|
||||
/// Stops uploading and closes what it holds open; an upload under way
|
||||
/// finishes first.
|
||||
Future<void> dispose() async {
|
||||
await _triggers.cancel();
|
||||
_stopped = true;
|
||||
await _uploads.idle;
|
||||
await _changes.close();
|
||||
}
|
||||
|
||||
AttachmentContent? _open(AttachmentRow row) {
|
||||
if (row.envelope.keyId == null) {
|
||||
// Readable: one the instance made up.
|
||||
return null;
|
||||
}
|
||||
return switch (_envelope.open(row.envelope, table: _table, id: row.id)) {
|
||||
Readable(:final payload) => decodeAttachment(payload),
|
||||
Locked() => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// What a file is sealed for: its row, so it opens for no other.
|
||||
static List<int> _fileContext(String id) => utf8.encode('$_table|$id|file');
|
||||
}
|
||||
|
||||
bool _always() => true;
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
||||
import 'package:halkyon_todo_flutter/src/feature/attachment/data/attachment_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// The person's attachments on their local database ([AttachmentGateway]),
|
||||
/// which the sync engine keeps in step with their other devices.
|
||||
class SyncAttachmentGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements AttachmentGateway {
|
||||
@override
|
||||
Future<List<AttachmentRow>> rows() async => [
|
||||
for (final row in await protocol.Attachment.db.find(
|
||||
_db,
|
||||
orderBy: (t) => t.id,
|
||||
))
|
||||
(
|
||||
id: row.id!.uuid,
|
||||
sha256: row.sha256,
|
||||
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(AttachmentRow row) async {
|
||||
await protocol.Attachment.db.insertRow(
|
||||
_db,
|
||||
protocol.Attachment(
|
||||
id: protocol.UuidValue.withValidation(row.id),
|
||||
sha256: row.sha256,
|
||||
keyId: row.envelope.keyId,
|
||||
nonce: row.envelope.nonce,
|
||||
payload: row.envelope.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(Iterable<String> ids) async {
|
||||
final uuids = <protocol.UuidValue>{
|
||||
for (final id in ids) protocol.UuidValue.withValidation(id),
|
||||
};
|
||||
if (uuids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _db.db.transaction((transaction) async {
|
||||
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
||||
// they last were, until the space goes. The hash stays: it says
|
||||
// nothing of the file, and the server needs no file for a deleted row.
|
||||
for (final row in await protocol.Attachment.db.find(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(uuids),
|
||||
transaction: transaction,
|
||||
)) {
|
||||
await protocol.Attachment.db.updateRow(
|
||||
_db,
|
||||
protocol.Attachment(
|
||||
id: row.id,
|
||||
spaceId: row.spaceId,
|
||||
sha256: row.sha256,
|
||||
payload: ByteData(0),
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
await protocol.Attachment.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(uuids),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A file attached to the task [taskId]'s description, or to its comment
|
||||
/// [commentId] (Halkyon's ADR 0043, D1, D8): its [name], [mimeType] and
|
||||
/// [byteSize] as it was added, and [sha256], the hash of the file before it
|
||||
/// was sealed, which tells the file opened from one changed.
|
||||
final class const AttachmentContent({
|
||||
required final String taskId,
|
||||
final String? commentId,
|
||||
required final String name,
|
||||
required final String mimeType,
|
||||
required final int byteSize,
|
||||
required final String sha256,
|
||||
required final DateTime addedAt,
|
||||
}) with Equatable {
|
||||
/// Whether it shows as a picture: an image the app can draw.
|
||||
bool get isImage => _drawn.contains(mimeType);
|
||||
|
||||
static const _drawn = {
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/bmp',
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
taskId,
|
||||
commentId,
|
||||
name,
|
||||
mimeType,
|
||||
byteSize,
|
||||
sha256,
|
||||
addedAt,
|
||||
];
|
||||
}
|
||||
|
||||
/// An attachment as kept: its [id], the hash of its sealed file as the
|
||||
/// server keeps it ([storedSha256]), and [content] null where it doesn't
|
||||
/// open here.
|
||||
final class const StoredAttachment(
|
||||
final String id,
|
||||
final String storedSha256,
|
||||
final AttachmentContent? content,
|
||||
) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, storedSha256, content];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue