diff --git a/docs/export/v1/act.schema.json b/docs/export/v1/act.schema.json new file mode 100644 index 00000000..4622b8ac --- /dev/null +++ b/docs/export/v1/act.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EvilCase export v1, !ukon.json of an act", + "type": "object", + "additionalProperties": false, + "required": [ + "type", "actId", "actNumber", "externalActNumber", "date", "title", "description", "direction", "caseId", + "contact", "labels", "comments", "files", "created", "updated", "path" + ], + "properties": { + "type": { "enum": ["act"] }, + "actId": { "$ref": "common.schema.json#/definitions/id" }, + "actNumber": { "type": "string" }, + "externalActNumber": { "$ref": "common.schema.json#/definitions/optionalText" }, + "date": { "type": "string", "format": "date" }, + "title": { "type": "string" }, + "description": { "description": "Markdown.", "$ref": "common.schema.json#/definitions/optionalText" }, + "direction": { "description": "From the case owner's point of view; null exactly where contact is.", "enum": ["Incoming", "Outgoing", null] }, + "caseId": { "$ref": "common.schema.json#/definitions/id" }, + "contact": { + "oneOf": [{ "type": "null" }, { "$ref": "common.schema.json#/definitions/contactReference" }] + }, + "labels": { "type": "array", "items": { "$ref": "common.schema.json#/definitions/labelReference" } }, + "comments": { "type": "array", "items": { "$ref": "common.schema.json#/definitions/comment" } }, + "files": { "type": "array", "items": { "$ref": "common.schema.json#/definitions/file" } }, + "created": { "$ref": "common.schema.json#/definitions/moment" }, + "updated": { "$ref": "common.schema.json#/definitions/optionalMoment" }, + "path": { "description": "The act's folder in the archive.", "type": "string" } + } +} diff --git a/docs/export/v1/case.schema.json b/docs/export/v1/case.schema.json new file mode 100644 index 00000000..5bb630f5 --- /dev/null +++ b/docs/export/v1/case.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EvilCase export v1, !spis.json of a case", + "type": "object", + "additionalProperties": false, + "required": [ + "type", "caseId", "caseNumber", "externalCaseNumber", "date", "title", "description", "status", "parentCaseId", + "contact", "labels", "comments", "files", "created", "updated", "path" + ], + "properties": { + "type": { "enum": ["case"] }, + "caseId": { "$ref": "common.schema.json#/definitions/id" }, + "caseNumber": { "type": "string" }, + "externalCaseNumber": { "$ref": "common.schema.json#/definitions/optionalText" }, + "date": { "type": "string", "format": "date" }, + "title": { "type": "string" }, + "description": { "description": "Markdown.", "$ref": "common.schema.json#/definitions/optionalText" }, + "status": { "enum": ["Active", "WaitingOnAuthority", "Closed"] }, + "parentCaseId": { "description": "Null for a root case.", "type": ["string", "null"], "format": "uuid" }, + "contact": { + "description": "The counterpart.", + "oneOf": [{ "type": "null" }, { "$ref": "common.schema.json#/definitions/contactReference" }] + }, + "labels": { "type": "array", "items": { "$ref": "common.schema.json#/definitions/labelReference" } }, + "comments": { "type": "array", "items": { "$ref": "common.schema.json#/definitions/comment" } }, + "files": { "description": "The case's own files, not those of its sub-cases or acts.", "type": "array", "items": { "$ref": "common.schema.json#/definitions/file" } }, + "created": { "$ref": "common.schema.json#/definitions/moment" }, + "updated": { "$ref": "common.schema.json#/definitions/optionalMoment" }, + "path": { "description": "The case's folder in the archive.", "type": "string" } + } +} diff --git a/docs/export/v1/common.schema.json b/docs/export/v1/common.schema.json new file mode 100644 index 00000000..d3b24cb1 --- /dev/null +++ b/docs/export/v1/common.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EvilCase export v1, shared definitions", + "definitions": { + "id": { + "type": "string", + "format": "uuid" + }, + "moment": { + "description": "ISO 8601 date and time in UTC.", + "type": "string", + "format": "date-time" + }, + "optionalMoment": { + "description": "ISO 8601 date and time in UTC; null where the record was never changed.", + "type": ["string", "null"], + "format": "date-time" + }, + "optionalText": { + "type": ["string", "null"] + }, + "contactReference": { + "type": "object", + "additionalProperties": false, + "required": ["contactId", "name"], + "properties": { + "contactId": { "$ref": "#/definitions/id" }, + "name": { "type": "string" } + } + }, + "labelReference": { + "type": "object", + "additionalProperties": false, + "required": ["labelId", "name"], + "properties": { + "labelId": { "$ref": "#/definitions/id" }, + "name": { "type": "string" } + } + }, + "comment": { + "type": "object", + "additionalProperties": false, + "required": ["commentId", "body", "authorEmail", "created", "updated"], + "properties": { + "commentId": { "$ref": "#/definitions/id" }, + "body": { "description": "Markdown.", "type": "string" }, + "authorEmail": { "type": "string" }, + "created": { "$ref": "#/definitions/moment" }, + "updated": { "$ref": "#/definitions/optionalMoment" } + } + }, + "file": { + "type": "object", + "additionalProperties": false, + "required": ["fileId", "fileName", "versions"], + "properties": { + "fileId": { "$ref": "#/definitions/id" }, + "fileName": { "description": "The name the file carries in the application.", "type": "string" }, + "versions": { + "description": "By number; the last is the current version.", + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/fileVersion" } + } + } + }, + "fileVersion": { + "type": "object", + "additionalProperties": false, + "required": ["number", "sizeBytes", "mediaType", "sha256", "created", "uploaderEmail", "path", "missing"], + "properties": { + "number": { "type": "integer", "minimum": 1 }, + "sizeBytes": { "type": "integer", "minimum": 0 }, + "mediaType": { "$ref": "#/definitions/optionalText" }, + "sha256": { "description": "SHA-256 of the content, lower-case hex.", "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "created": { "description": "When the version was uploaded.", "$ref": "#/definitions/moment" }, + "uploaderEmail": { "type": "string" }, + "path": { "description": "The version's entry in the archive; null where the content was missing.", "$ref": "#/definitions/optionalText" }, + "missing": { "description": "The content was not on disk and is not in the archive.", "type": "boolean" } + } + } + } +} diff --git a/docs/export/v1/contact.schema.json b/docs/export/v1/contact.schema.json new file mode 100644 index 00000000..10a0dc81 --- /dev/null +++ b/docs/export/v1/contact.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EvilCase export v1, a contact in Kontakty", + "type": "object", + "additionalProperties": false, + "required": ["type", "contactId", "kind", "name", "dataBoxId", "address", "created", "updated", "path"], + "properties": { + "type": { "enum": ["contact"] }, + "contactId": { "$ref": "common.schema.json#/definitions/id" }, + "kind": { "enum": ["Authority", "Official", "Person"] }, + "name": { "type": "string" }, + "dataBoxId": { "$ref": "common.schema.json#/definitions/optionalText" }, + "address": { "$ref": "common.schema.json#/definitions/optionalText" }, + "created": { "$ref": "common.schema.json#/definitions/moment" }, + "updated": { "$ref": "common.schema.json#/definitions/optionalMoment" }, + "path": { "description": "The contact's document in the archive.", "type": "string" } + } +} diff --git a/docs/export/v1/export.schema.json b/docs/export/v1/export.schema.json new file mode 100644 index 00000000..cab23fbc --- /dev/null +++ b/docs/export/v1/export.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EvilCase export v1, export.json", + "type": "object", + "additionalProperties": false, + "required": ["type", "formatVersion", "exported", "applicationVersion", "tenantId", "tenantName"], + "properties": { + "type": { "enum": ["export"] }, + "formatVersion": { "enum": [1] }, + "exported": { "description": "When the export started; the archive holds the data as it stood then.", "$ref": "common.schema.json#/definitions/moment" }, + "applicationVersion": { "type": "string" }, + "tenantId": { "$ref": "common.schema.json#/definitions/id" }, + "tenantName": { "type": "string" } + } +} diff --git a/docs/export/v1/labels.schema.json b/docs/export/v1/labels.schema.json new file mode 100644 index 00000000..e8be2158 --- /dev/null +++ b/docs/export/v1/labels.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EvilCase export v1, Štítky/!stitky.json", + "type": "object", + "additionalProperties": false, + "required": ["type", "labels"], + "properties": { + "type": { "enum": ["labels"] }, + "labels": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["labelId", "name", "color"], + "properties": { + "labelId": { "$ref": "common.schema.json#/definitions/id" }, + "name": { "type": "string" }, + "color": { "enum": ["Blue", "Azure", "Indigo", "Purple", "Pink", "Red", "Orange", "Yellow", "Lime", "Green", "Teal", "Cyan", "Gray"] } + } + } + } + } +} diff --git a/docs/sdd/README.md b/docs/sdd/README.md index 063dbdb4..b6a1ebc1 100644 --- a/docs/sdd/README.md +++ b/docs/sdd/README.md @@ -14,7 +14,7 @@ SDD (software design document) závazně popisuje jednu oblast návrhu EvilCase. ## Pořadí čtení Architektura 001 jako první. Průřezová 002–005 a 018 platí pro každou změnu ve své oblasti; -základ 006–008 (tenance, model, číslování), agendy 009–013 a 019, aplikace 015–017, 020 a 021. +základ 006–008 (tenance, model, číslování), agendy 009–013 a 019, aplikace 015–017, 020–022. ## Mapa SDD ↔ milník @@ -40,3 +40,4 @@ základ 006–008 (tenance, model, číslování), agendy 009–013 a 019, aplik | [019](sdd-019-stitky.md) | Štítky a nastavení | M8 | | [020](sdd-020-vzhled.md) | Vzhled | M9 | | [021](sdd-021-vyhledavani.md) | Vyhledávání | M9 | +| [022](sdd-022-export.md) | Export | — | diff --git a/docs/sdd/sdd-016-navigace-a-vzhled.md b/docs/sdd/sdd-016-navigace-a-vzhled.md index f90a9b49..bd18c6d6 100644 --- a/docs/sdd/sdd-016-navigace-a-vzhled.md +++ b/docs/sdd/sdd-016-navigace-a-vzhled.md @@ -29,6 +29,7 @@ URL nesou UUID entit, nikdy jejich čísla: | `/contacts` | kontakty | | `/contacts/{id}` | detail kontaktu | | `/settings` | nastavení (SDD-019) | +| `/settings/export` | export dat tenanta (SDD-019, SDD-022) | | `/login` | přihlášení | Neznámá routa vykreslí stav nenalezeno uvnitř aplikace, neznámé id v routě prázdný stav diff --git a/docs/sdd/sdd-019-stitky.md b/docs/sdd/sdd-019-stitky.md index 9c3e94b7..e3c5b866 100644 --- a/docs/sdd/sdd-019-stitky.md +++ b/docs/sdd/sdd-019-stitky.md @@ -3,7 +3,7 @@ - **Stav:** platí - **Milníky:** M8 - **Související SDD:** [007](sdd-007-domenovy-model.md), [009](sdd-009-spisy.md), - [010](sdd-010-ukony.md), [016](sdd-016-navigace-a-vzhled.md) + [010](sdd-010-ukony.md), [016](sdd-016-navigace-a-vzhled.md), [022](sdd-022-export.md) ## Rozsah @@ -25,7 +25,13 @@ ničem. Tutéž dvojici nese nejvýše jednou. Spis ani úkon nemá počet ští ### Nastavení -`/settings` je agenda nastavení tenantu; zatím nese jedinou sekci, Štítky. Sekce ukazuje štítky +`/settings` je agenda nastavení tenantu se sekcemi Štítky (`/settings`) a Export +(`/settings/export`). Na desktopu je mezi sekcemi svislý panel, na mobilu řada pilulek. + +Sekce Export popíše, co archiv nese a co ne, a nese tlačítko Exportovat vše s názvem souboru, +pod kterým se archiv stáhne. Export řídí [SDD-022](sdd-022-export.md). + +Sekce Štítky ukazuje štítky seřazené podle názvu, u každého počet spisů a počet úkonů, které ho nesou, a nechá štítek založit, přejmenovat, přebarvit a smazat. Název, který už nese jiný štítek, se odmítne 409 (SDD-004). Smazání štítku ho sejme ze všech spisů a úkonů, které ho nesly; spis ani úkon tím diff --git a/docs/sdd/sdd-022-export.md b/docs/sdd/sdd-022-export.md new file mode 100644 index 00000000..fb7f8509 --- /dev/null +++ b/docs/sdd/sdd-022-export.md @@ -0,0 +1,110 @@ +# SDD-022 — Export + +- **Stav:** platí +- **Milníky:** — +- **Související SDD:** [006](sdd-006-tenance-a-ucty.md), [007](sdd-007-domenovy-model.md), + [012](sdd-012-soubory.md), [019](sdd-019-stitky.md) + +## Rozsah + +Ruční export všech dat tenanta do jednoho archivu zip: spouštění, struktura archivu a formát +dokumentů JSON. Sekci nastavení, ze které se export spouští, drží SDD-019. Plánovaný export, +rotace, ukládání exportů, archiv s heslem a import do rozsahu nepatří. + +## Popis + +### Spuštění + +Tlačítko Exportovat vše v sekci Export nastavení stáhne `GET /api/export` obyčejným stažením +prohlížečem, stejně jako Stáhnout vše (SDD-012). Exportovat smí každý přihlášený uživatel tenanta; +nepřihlášený dostane 401. Archiv nese jen data tenanta volajícího (SDD-006). + +Archiv se jmenuje `EvilCase export 2026-10-30.zip` podle pražského data exportu. Posílá se +průběžně, jak se píše, bez přípravy předem a bez omezení velikosti, i nad 4 GB. Všechna data se +čtou z jednoho snímku databáze pořízeného na začátku exportu; změna během exportu v archivu není. +Přerušené stažení export zastaví. Chyba po prvním bajtu stažení přeruší; prohlížeč nikdy neuloží +useknutý archiv jako celý. + +### Struktura + +``` +export.json +Spisy/ + 2026-08-21 Přestupek/ + !spis.json + Smlouva (verze 1).pdf + Smlouva.pdf + 2026-09-01 Odvolání/ + !spis.json + 2026-09-02 Podání/ + !ukon.json + Podání.pdf +Kontakty/ + Krajský úřad.json +Štítky/ + !stitky.json +``` + +- `Spisy/` nese kořenové spisy. Složka spisu nese `!spis.json`, soubory spisu s verzemi a jednu + složku za každý podspis a každý úkon, bez mezisložek. Podspisy se zanořují do libovolné + hloubky. Složka úkonu nese `!ukon.json` a soubory úkonu s verzemi. +- Složka spisu i úkonu se jmenuje ` `, datum `yyyy-MM-dd`. Název ztratí znaky, + které jméno souboru nesmí obsahovat, jako ve Stáhnout vše, a zkrátí se na 80 znaků. Název, ze + kterého nic nezbude, nahradí „Spis“ nebo „Úkon“. +- Soubory leží jako ve Stáhnout vše včetně verzí: aktuální verze pod jménem souboru, starší vedle + ní jako „Smlouva (verze 2).pdf“. +- `Kontakty/.json` je jeden kontakt; jméno se upraví jako název spisu, prázdné nahradí + „Kontakt“. +- `Štítky/!stitky.json` nese všechny štítky tenanta; tenant bez štítků ho nemá. +- Jméno, které už ve složce je (bez ohledu na velikost písmen), dostane „ (2)“, „ (3)“ a další + volné číslo, u souboru před příponou. Jméno si zabírají v tomto pořadí: dokument spisu nebo + úkonu, složky podspisů, složky úkonů, soubory. Dokument JSON nese skutečný název i cestu + v archivu. +- Položky archivu nesou pražský čas, soubor okamžik nahrání své verze, dokument spisu, úkonu + a kontaktu okamžik poslední změny záznamu, ostatní začátek exportu. Jména položek jsou v UTF-8. +- Verze, jejíž obsah na disku chybí, export nezastaví: v archivu není, její dokument ji označí + `missing: true` a zaloguje se varování. +- Tenant bez dat vydá `export.json` a prázdné složky `Spisy`, `Kontakty` a `Štítky`. + +### Dokumenty JSON + +Formát je verzovaný; každá změna struktury zvýší verzi formátu i jejích schémat. Schéma JSON +každého typu dokumentu leží v [`docs/export/v1/`](../export/v1/): `export.schema.json`, +`case.schema.json`, `act.schema.json`, `contact.schema.json`, `labels.schema.json` +a společné definice `common.schema.json`. + +- Klíče jsou anglicky a jmenují se jako v kontraktu API; datum je `yyyy-MM-dd`, okamžik ISO 8601 + v UTC; dokumenty nesou id. +- Pole bez hodnoty je přítomné jako `null`. +- Každý dokument nese `type`: `export`, `case`, `act`, `contact` nebo `labels`. +- `export.json`: verze formátu, okamžik exportu, verze aplikace, id a název tenanta. +- `!spis.json`: všechna pole spisu, id nadřízeného spisu, protistrana jako id a jméno, štítky + jako id a název, komentáře, soubory, okamžik založení a poslední změny, cesta složky. +- `!ukon.json`: všechna pole úkonu, id spisu, kontakt jako id a jméno, štítky jako id a název, + komentáře, soubory, okamžik založení a poslední změny, cesta složky. +- Komentář: id, text, e-mail autora, okamžik založení a poslední změny. +- Soubor: id, jméno a u každé verze číslo, velikost, typ obsahu, SHA-256, okamžik nahrání, e-mail + toho, kdo ji nahrál, cesta v archivu a příznak `missing`. +- Kontakt: všechna pole, okamžik založení a poslední změny, cesta v archivu. +- Štítky: u každého id, název a barva. + +Export nenese historii změn, smazané záznamy, uživatele, refresh tokeny ani data jiných tenantů. + +### Logování + +Začátek a konec exportu se logují s id tenanta, počty spisů, úkonů, kontaktů, štítků a verzí +souborů, počtem chybějících obsahů a velikostí archivu; obsah nikdy. + +## Rozhodnutí + +- Spuštění: příprava na pozadí a odkaz ke stažení / průběžné stažení. Platí průběžné stažení. +- Oprávnění: jen správce / každý přihlášený uživatel tenanta. Platí každý přihlášený uživatel. +- Struktura: plochý seznam dokumentů / složky podle stromu spisů. Platí složky podle stromu. +- Formát: kontrakt API / vlastní dokumenty exportu. Platí vlastní dokumenty, aby se kontrakt API + mohl měnit bez změny formátu. +- Chybějící obsah verze: přerušit export / označit a pokračovat. Platí označit a pokračovat. + +## Dopady + +Sekci Export v nastavení drží SDD-019, routu SDD-016. Jména souborů a verzí sdílí export se +Stáhnout vše (SDD-012). diff --git a/src/Api/EvilCase.Api/Controllers/FileTransferController.cs b/src/Api/EvilCase.Api/Controllers/FileTransferController.cs index d1dab725..40c9555b 100644 --- a/src/Api/EvilCase.Api/Controllers/FileTransferController.cs +++ b/src/Api/EvilCase.Api/Controllers/FileTransferController.cs @@ -2,6 +2,7 @@ using EvilBrains.EvilCase.Api.Contract.Acts; using EvilBrains.EvilCase.Api.Contract.Cases; using EvilBrains.EvilCase.Api.Contract.Files; +using EvilBrains.EvilCase.Business.Export; using EvilBrains.EvilCase.Business.Files; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -164,6 +165,25 @@ public async Task DownloadActFileArchive( return new EmptyResult(); } + /// + /// Everything the caller's tenant holds (SDD-022). + /// + [HttpGet("export")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task DownloadTenantExport([FromServices] ITenantExporter exporter, [FromServices] ILogger logger, CancellationToken token) + { + var export = exporter.CreateTenantExport(); + + await this.SendZip( + export.FileName, + async target => await exporter.WriteTenantExport(export, target, token), + exception => logger.LogError(exception, "The tenant export was cut short"), + token); + + return new EmptyResult(); + } + // IFormFile.ContentType is never null, only empty, when a multipart part carries no Content-Type header. private static string? NormalizeMediaType(string mediaType) { @@ -193,25 +213,34 @@ public async Task DownloadActFileArchive( }; } + private async Task SendFileArchive(IFileArchiveReader archives, ILogger logger, FileArchive archive, CancellationToken token) + { + await this.SendZip( + archive.FileName, + async target => await archives.WriteFileArchive(archive, target, token), + exception => logger.LogError(exception, "File archive of {OwnerId} was cut short", archive.OwnerId), + token); + } + /// /// A failure before the first byte is an ordinary error response. A failure after it aborts the response, so the /// browser fails the download instead of saving a cut archive. /// - private async Task SendFileArchive(IFileArchiveReader archives, ILogger logger, FileArchive archive, CancellationToken token) + private async Task SendZip(string fileName, Func write, Action logCut, CancellationToken token) { var disposition = new ContentDispositionHeaderValue("attachment"); - disposition.SetHttpFileName(archive.FileName); + disposition.SetHttpFileName(fileName); this.Response.Headers.ContentDisposition = disposition.ToString(); this.Response.ContentType = "application/zip"; try { - await archives.WriteFileArchive(archive, this.Response.Body, token); + await write(this.Response.Body); } catch (Exception exception) when (this.Response.HasStarted) { if (!token.IsCancellationRequested) - logger.LogError(exception, "File archive of {OwnerId} was cut short", archive.OwnerId); + logCut(exception); this.HttpContext.Abort(); } diff --git a/src/App/EvilCase.App/Components/SettingsNav.razor b/src/App/EvilCase.App/Components/SettingsNav.razor new file mode 100644 index 00000000..c6a61087 --- /dev/null +++ b/src/App/EvilCase.App/Components/SettingsNav.razor @@ -0,0 +1,26 @@ +@inject NavigationManager Navigation + + + +@code { + private static readonly (string Href, string Text, string Icon)[] Items = + [ + ("/settings", "Štítky", AppIcons.Tag), + ("/settings/export", "Export", AppIcons.ArrowBarToDown), + ]; + + private bool IsCurrent(string href) + { + var path = "/" + this.Navigation.ToBaseRelativePath(this.Navigation.Uri).Split('?', '#')[0].TrimEnd('/'); + + return string.Equals(path, href, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/App/EvilCase.App/Icons/AppIcons.cs b/src/App/EvilCase.App/Icons/AppIcons.cs index e7857132..4241692c 100644 --- a/src/App/EvilCase.App/Icons/AppIcons.cs +++ b/src/App/EvilCase.App/Icons/AppIcons.cs @@ -27,6 +27,12 @@ public static class AppIcons + "" + ""; + public const string ArrowBarToDown = "" + + "" + + "" + + "" + + ""; + public const string ArrowForwardUp = "" + "" + ""; diff --git a/src/App/EvilCase.App/Pages/Settings.razor b/src/App/EvilCase.App/Pages/Settings.razor index e89d946a..857c4d67 100644 --- a/src/App/EvilCase.App/Pages/Settings.razor +++ b/src/App/EvilCase.App/Pages/Settings.razor @@ -11,9 +11,7 @@
- +
diff --git a/src/App/EvilCase.App/Pages/SettingsExport.razor b/src/App/EvilCase.App/Pages/SettingsExport.razor new file mode 100644 index 00000000..0b877f52 --- /dev/null +++ b/src/App/EvilCase.App/Pages/SettingsExport.razor @@ -0,0 +1,50 @@ +@page "/settings/export" +@inject IFileDownloader Downloader + +Export – EvilCase + +
+ + +
+ + +
+ +
+
+

Export

+ Všechna data v jednom archivu ZIP +
+
+ +
+

+ Archiv obsahuje spisy s podspisy a úkony, komentáře, soubory se všemi verzemi, kontakty a štítky. + Každý spis, úkon a kontakt má vlastní soubor JSON, takže se archiv dá číst i bez aplikace. +

+

+ Historie změn, smazané záznamy a uživatelé se neexportují. Export zachytí stav ve chvíli, kdy začne stahování. +

+
+ Exportovat vše + Stáhne se jako @this.FileName +
+
+
+
+
+
+
+ +@code { + private const string ExportUrl = "/api/export"; + + // The server names the archive by the Prague date; the browser's date is that date in Czechia. + private string FileName => $"EvilCase export {DateOnly.FromDateTime(DateTime.Now).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}.zip"; + + private async Task Export() + { + await this.Downloader.DownloadArchive(ExportUrl, CancellationToken.None); + } +} diff --git a/src/App/EvilCase.App/wwwroot/css/ec-components.css b/src/App/EvilCase.App/wwwroot/css/ec-components.css index 741bd278..0689a5d8 100644 --- a/src/App/EvilCase.App/wwwroot/css/ec-components.css +++ b/src/App/EvilCase.App/wwwroot/css/ec-components.css @@ -3210,6 +3210,7 @@ html.ec-scroll-locked { .ec-settings-nav-item { display: flex; align-items: center; + gap: var(--ec-space-3); height: var(--ec-control-lg); padding: 0 var(--ec-space-4); border-radius: var(--ec-radius-lg); @@ -3218,12 +3219,51 @@ html.ec-scroll-locked { text-decoration: none; } +.ec-settings-nav-item:hover { + background: var(--ec-surface-header); +} + .ec-settings-nav-item[aria-current="page"] { background: var(--ec-accent-soft); color: var(--ec-accent-text); font-weight: var(--ec-weight-semibold); } +.ec-settings-export { + max-width: var(--ec-settings-export-max); +} + +.ec-export { + display: flex; + flex-direction: column; + gap: var(--ec-space-4); + padding: 0 var(--ec-space-6) var(--ec-space-6); +} + +.ec-export-text, +.ec-export-note { + margin: 0; + line-height: var(--ec-leading-body); +} + +.ec-export-note, +.ec-export-hint { + font-size: var(--ec-text-sm); + color: var(--ec-text-muted); +} + +.ec-export-action { + display: flex; + align-items: center; + gap: var(--ec-space-4); + margin-top: var(--ec-space-2); +} + +.ec-export-file { + font-family: var(--ec-font-mono); + white-space: nowrap; +} + .ec-card-heading { display: flex; flex-direction: column; @@ -4341,6 +4381,7 @@ html.ec-scroll-locked { .ec-settings-nav { flex-direction: row; + flex-wrap: wrap; gap: var(--ec-space-3); padding: 0; border: 0; @@ -4349,7 +4390,23 @@ html.ec-scroll-locked { .ec-settings-nav-item { height: var(--ec-touch-min); + border: 1px solid var(--ec-border); border-radius: var(--ec-radius-pill); + background: var(--ec-surface); + } + + .ec-settings-nav-item[aria-current="page"] { + border-color: transparent; + } + + .ec-export-action { + flex-direction: column; + align-items: stretch; + text-align: center; + } + + .ec-export-action .ec-button { + min-height: var(--ec-touch-min); } .ec-settings-layout .ec-card-heading { diff --git a/src/App/EvilCase.App/wwwroot/css/ec-tokens.css b/src/App/EvilCase.App/wwwroot/css/ec-tokens.css index 45253c20..ac6538c6 100644 --- a/src/App/EvilCase.App/wwwroot/css/ec-tokens.css +++ b/src/App/EvilCase.App/wwwroot/css/ec-tokens.css @@ -205,6 +205,7 @@ --ec-content-padding: 32px; --ec-sidebar-width: 380px; --ec-settings-rail: 220px; + --ec-settings-export-max: 760px; --ec-popover-width: 320px; --ec-menu-width: 240px; --ec-viewport-height: 100vh; diff --git a/src/Business/EvilCase.Business/Bootstrap.cs b/src/Business/EvilCase.Business/Bootstrap.cs index ff3e3027..1369ddd1 100644 --- a/src/Business/EvilCase.Business/Bootstrap.cs +++ b/src/Business/EvilCase.Business/Bootstrap.cs @@ -2,6 +2,7 @@ using EvilBrains.EvilCase.Business.Cases; using EvilBrains.EvilCase.Business.Comments; using EvilBrains.EvilCase.Business.Contacts; +using EvilBrains.EvilCase.Business.Export; using EvilBrains.EvilCase.Business.Files; using EvilBrains.EvilCase.Business.Files.Signatures; using EvilBrains.EvilCase.Business.Labels; @@ -41,6 +42,7 @@ public static IServiceCollection AddEvilCaseBusiness(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddMemoryCache(); services.TryAddSingleton(TimeProvider.System); services.AddSingleton(static _ => SignatureTrust.LoadPostSignum()); diff --git a/src/Business/EvilCase.Business/Export/ExportManifest.cs b/src/Business/EvilCase.Business/Export/ExportManifest.cs new file mode 100644 index 00000000..daf00a5d --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportManifest.cs @@ -0,0 +1,19 @@ +namespace EvilBrains.EvilCase.Business.Export; + +/// +/// export.json, the archive's root document. +/// +internal sealed record ExportManifest +{ + public string Type { get; } = "export"; + + public required int FormatVersion { get; init; } + + public required DateTime Exported { get; init; } + + public required string ApplicationVersion { get; init; } + + public required Guid TenantId { get; init; } + + public required string TenantName { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/ExportedAct.cs b/src/Business/EvilCase.Business/Export/ExportedAct.cs new file mode 100644 index 00000000..82c77946 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportedAct.cs @@ -0,0 +1,44 @@ +using EvilBrains.EvilCase.Domain.Acts; + +namespace EvilBrains.EvilCase.Business.Export; + +/// +/// !ukon.json in the act's folder. +/// +internal sealed record ExportedAct +{ + public string Type { get; } = "act"; + + public required Guid ActId { get; init; } + + public required string ActNumber { get; init; } + + public required string? ExternalActNumber { get; init; } + + public required DateOnly Date { get; init; } + + public required string Title { get; init; } + + public required string? Description { get; init; } + + public required ActDirection? Direction { get; init; } + + public required Guid CaseId { get; init; } + + public required ExportedContactReference? Contact { get; init; } + + public required IReadOnlyList Labels { get; init; } + + public IReadOnlyList Comments { get; init; } = []; + + public IReadOnlyList Files { get; init; } = []; + + public required DateTime Created { get; init; } + + public required DateTime? Updated { get; init; } + + /// + /// The act's folder in the archive. + /// + public string Path { get; init; } = ""; +} diff --git a/src/Business/EvilCase.Business/Export/ExportedCase.cs b/src/Business/EvilCase.Business/Export/ExportedCase.cs new file mode 100644 index 00000000..da2d57d5 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportedCase.cs @@ -0,0 +1,44 @@ +using EvilBrains.EvilCase.Domain.Cases; + +namespace EvilBrains.EvilCase.Business.Export; + +/// +/// !spis.json in the case's folder. +/// +internal sealed record ExportedCase +{ + public string Type { get; } = "case"; + + public required Guid CaseId { get; init; } + + public required string CaseNumber { get; init; } + + public required string? ExternalCaseNumber { get; init; } + + public required DateOnly Date { get; init; } + + public required string Title { get; init; } + + public required string? Description { get; init; } + + public required CaseStatus Status { get; init; } + + public required Guid? ParentCaseId { get; init; } + + public required ExportedContactReference? Contact { get; init; } + + public required IReadOnlyList Labels { get; init; } + + public IReadOnlyList Comments { get; init; } = []; + + public IReadOnlyList Files { get; init; } = []; + + public required DateTime Created { get; init; } + + public required DateTime? Updated { get; init; } + + /// + /// The case's folder in the archive. + /// + public string Path { get; init; } = ""; +} diff --git a/src/Business/EvilCase.Business/Export/ExportedComment.cs b/src/Business/EvilCase.Business/Export/ExportedComment.cs new file mode 100644 index 00000000..fd5fdd18 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportedComment.cs @@ -0,0 +1,14 @@ +namespace EvilBrains.EvilCase.Business.Export; + +internal sealed record ExportedComment +{ + public required Guid CommentId { get; init; } + + public required string Body { get; init; } + + public required string AuthorEmail { get; init; } + + public required DateTime Created { get; init; } + + public required DateTime? Updated { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/ExportedContact.cs b/src/Business/EvilCase.Business/Export/ExportedContact.cs new file mode 100644 index 00000000..1142b65d --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportedContact.cs @@ -0,0 +1,30 @@ +using EvilBrains.EvilCase.Domain.Contacts; + +namespace EvilBrains.EvilCase.Business.Export; + +/// +/// One contact's document in Kontakty. +/// +internal sealed record ExportedContact +{ + public string Type { get; } = "contact"; + + public required Guid ContactId { get; init; } + + public required ContactKind Kind { get; init; } + + public required string Name { get; init; } + + public required string? DataBoxId { get; init; } + + public required string? Address { get; init; } + + public required DateTime Created { get; init; } + + public required DateTime? Updated { get; init; } + + /// + /// The contact's document in the archive. + /// + public string Path { get; init; } = ""; +} diff --git a/src/Business/EvilCase.Business/Export/ExportedContactReference.cs b/src/Business/EvilCase.Business/Export/ExportedContactReference.cs new file mode 100644 index 00000000..365fa4a0 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportedContactReference.cs @@ -0,0 +1,8 @@ +namespace EvilBrains.EvilCase.Business.Export; + +internal sealed record ExportedContactReference +{ + public required Guid ContactId { get; init; } + + public required string Name { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/ExportedFile.cs b/src/Business/EvilCase.Business/Export/ExportedFile.cs new file mode 100644 index 00000000..0e7d3c59 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportedFile.cs @@ -0,0 +1,13 @@ +namespace EvilBrains.EvilCase.Business.Export; + +internal sealed record ExportedFile +{ + public required Guid FileId { get; init; } + + public required string FileName { get; init; } + + /// + /// By number; the last is the current version. + /// + public required IReadOnlyList Versions { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/ExportedFileVersion.cs b/src/Business/EvilCase.Business/Export/ExportedFileVersion.cs new file mode 100644 index 00000000..260ca016 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportedFileVersion.cs @@ -0,0 +1,23 @@ +namespace EvilBrains.EvilCase.Business.Export; + +internal sealed record ExportedFileVersion +{ + public required int Number { get; init; } + + public required long SizeBytes { get; init; } + + public required string? MediaType { get; init; } + + public required string Sha256 { get; init; } + + public required DateTime Created { get; init; } + + public required string UploaderEmail { get; init; } + + /// + /// The version's entry in the archive; null where its blob is missing. + /// + public required string? Path { get; init; } + + public required bool Missing { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/ExportedLabel.cs b/src/Business/EvilCase.Business/Export/ExportedLabel.cs new file mode 100644 index 00000000..3a2c0537 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportedLabel.cs @@ -0,0 +1,12 @@ +using EvilBrains.EvilCase.Domain.Labels; + +namespace EvilBrains.EvilCase.Business.Export; + +internal sealed record ExportedLabel +{ + public required Guid LabelId { get; init; } + + public required string Name { get; init; } + + public required LabelColor Color { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/ExportedLabelList.cs b/src/Business/EvilCase.Business/Export/ExportedLabelList.cs new file mode 100644 index 00000000..e18ad820 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportedLabelList.cs @@ -0,0 +1,11 @@ +namespace EvilBrains.EvilCase.Business.Export; + +/// +/// !stitky.json in Štítky. +/// +internal sealed record ExportedLabelList +{ + public string Type { get; } = "labels"; + + public required IReadOnlyList Labels { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/ExportedLabelReference.cs b/src/Business/EvilCase.Business/Export/ExportedLabelReference.cs new file mode 100644 index 00000000..96b1ea74 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportedLabelReference.cs @@ -0,0 +1,8 @@ +namespace EvilBrains.EvilCase.Business.Export; + +internal sealed record ExportedLabelReference +{ + public required Guid LabelId { get; init; } + + public required string Name { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/ExportedVersionRow.cs b/src/Business/EvilCase.Business/Export/ExportedVersionRow.cs new file mode 100644 index 00000000..87b378b7 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ExportedVersionRow.cs @@ -0,0 +1,27 @@ +namespace EvilBrains.EvilCase.Business.Export; + +/// +/// A file version as the export reads it, before it has an entry in the archive. +/// +internal sealed record ExportedVersionRow +{ + public required Guid FileVersionId { get; init; } + + public required Guid FileId { get; init; } + + public required string FileName { get; init; } + + public required int Number { get; init; } + + public required long SizeBytes { get; init; } + + public required string? MediaType { get; init; } + + public required string ContentHash { get; init; } + + public required DateTime Created { get; init; } + + public required string UploaderEmail { get; init; } + + public required string StoragePath { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/ITenantExporter.cs b/src/Business/EvilCase.Business/Export/ITenantExporter.cs new file mode 100644 index 00000000..228508f1 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/ITenantExporter.cs @@ -0,0 +1,18 @@ +namespace EvilBrains.EvilCase.Business.Export; + +/// +/// Everything the caller's tenant holds as one zip archive (SDD-022). +/// +public interface ITenantExporter +{ + /// + /// Names the export by the Prague date of now. + /// + public TenantExport CreateTenantExport(); + + /// + /// Streams the archive to the target from one database snapshot, writing only asynchronously. A failure or a + /// cancellation throws, and what was written then lacks the archive's end. + /// + public Task WriteTenantExport(TenantExport export, Stream target, CancellationToken token); +} diff --git a/src/Business/EvilCase.Business/Export/OutlinedAct.cs b/src/Business/EvilCase.Business/Export/OutlinedAct.cs new file mode 100644 index 00000000..95379322 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/OutlinedAct.cs @@ -0,0 +1,15 @@ +namespace EvilBrains.EvilCase.Business.Export; + +/// +/// What an act's folder is named and placed by; the export holds every act this way at once. +/// +internal sealed record OutlinedAct +{ + public required Guid ActId { get; init; } + + public required Guid CaseId { get; init; } + + public required DateOnly Date { get; init; } + + public required string Title { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/OutlinedCase.cs b/src/Business/EvilCase.Business/Export/OutlinedCase.cs new file mode 100644 index 00000000..261bca91 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/OutlinedCase.cs @@ -0,0 +1,15 @@ +namespace EvilBrains.EvilCase.Business.Export; + +/// +/// What a case's folder is named and placed by; the export holds every case this way at once. +/// +internal sealed record OutlinedCase +{ + public required Guid CaseId { get; init; } + + public required Guid? ParentCaseId { get; init; } + + public required DateOnly Date { get; init; } + + public required string Title { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/TenantExport.cs b/src/Business/EvilCase.Business/Export/TenantExport.cs new file mode 100644 index 00000000..b64be7ce --- /dev/null +++ b/src/Business/EvilCase.Business/Export/TenantExport.cs @@ -0,0 +1,11 @@ +namespace EvilBrains.EvilCase.Business.Export; + +public sealed record TenantExport +{ + public required string FileName { get; init; } + + /// + /// The export time export.json carries. + /// + public required DateTimeOffset Started { get; init; } +} diff --git a/src/Business/EvilCase.Business/Export/TenantExportLayout.cs b/src/Business/EvilCase.Business/Export/TenantExportLayout.cs new file mode 100644 index 00000000..770e47d7 --- /dev/null +++ b/src/Business/EvilCase.Business/Export/TenantExportLayout.cs @@ -0,0 +1,70 @@ +using EvilBrains.EvilCase.Business.Files; + +namespace EvilBrains.EvilCase.Business.Export; + +/// +/// The names in a tenant export (SDD-022). +/// +internal static class TenantExportLayout +{ + /// + /// Raised with every change of the structure, together with the schemas in docs/export. + /// + public const int FormatVersion = 1; + + public const string ManifestName = "export.json"; + + public const string CasesFolder = "Spisy"; + + public const string ContactsFolder = "Kontakty"; + + public const string LabelsFolder = "Štítky"; + + public const string CaseDocumentName = "!spis.json"; + + public const string ActDocumentName = "!ukon.json"; + + public const string LabelsDocumentName = "!stitky.json"; + + public const string CaseFallback = "Spis"; + + public const string ActFallback = "Úkon"; + + private const string ContactFallback = "Kontakt"; + + private const int MaxTitleLength = 80; + + public static string ArchiveName(DateTimeOffset started) + { + var date = TimeZoneInfo.ConvertTime(started, FileArchiveLayout.Prague).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + return $"EvilCase export {date}.zip"; + } + + /// + /// "yyyy-MM-dd Title" of a case or an act, before any collision suffix. + /// + public static string FolderName(DateOnly date, string title, string fallback) + { + return $"{date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)} {Title(title, fallback)}"; + } + + public static string ContactDocumentName(string name) + { + return Title(name, ContactFallback) + ".json"; + } + + // The cut can leave a space or a dot at the end, which a file system drops; the first character is neither. + private static string Title(string title, string fallback) + { + var safe = FileArchiveLayout.SafeEntryName(title, fallback); + + if (safe.Length <= MaxTitleLength) + return safe; + + // A cut between the two halves of a surrogate pair would leave a character no encoding can write. + var length = char.IsHighSurrogate(safe[MaxTitleLength - 1]) ? MaxTitleLength - 1 : MaxTitleLength; + + return safe[..length].TrimEnd(' ', '.'); + } +} diff --git a/src/Business/EvilCase.Business/Export/TenantExportQuery.cs b/src/Business/EvilCase.Business/Export/TenantExportQuery.cs new file mode 100644 index 00000000..41036ccc --- /dev/null +++ b/src/Business/EvilCase.Business/Export/TenantExportQuery.cs @@ -0,0 +1,179 @@ +using EvilBrains.EvilCase.Business.Entities; +using EvilBrains.EvilCase.Data.DbContexts; +using EvilBrains.EvilCase.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace EvilBrains.EvilCase.Business.Export; + +internal static class TenantExportQuery +{ + /// + /// The order folders claim their names in: by date, then by filing. + /// + public static IQueryable AsOutlinedCases(this IQueryable cases) + { + return cases + .OrderBy(static @case => @case.Date) + .ThenBy(static @case => @case.Created) + .ThenBy(static @case => @case.Id) + .Select(static @case => new OutlinedCase + { + CaseId = @case.Id, + ParentCaseId = @case.ParentCaseId, + Date = @case.Date, + Title = @case.Title, + }); + } + + /// + /// The order folders claim their names in: by date, then by filing. + /// + public static IQueryable AsOutlinedActs(this IQueryable acts) + { + return acts + .OrderBy(static act => act.Date) + .ThenBy(static act => act.Created) + .ThenBy(static act => act.Id) + .Select(static act => new OutlinedAct + { + ActId = act.Id, + CaseId = act.CaseId, + Date = act.Date, + Title = act.Title, + }); + } + + /// + /// The case's own fields, its comments and files left to the reader. + /// + public static async Task ReadExportedCase(this IQueryable cases, Guid caseId, CancellationToken token) + { + return await cases + .WithId(caseId) + .Select(static @case => new ExportedCase + { + CaseId = @case.Id, + CaseNumber = @case.CaseNumber, + ExternalCaseNumber = @case.ExternalCaseNumber, + Date = @case.Date, + Title = @case.Title, + Description = @case.Description, + Status = @case.Status, + ParentCaseId = @case.ParentCaseId, + Contact = @case.Contact == null ? null : new ExportedContactReference { ContactId = @case.Contact.Id, Name = @case.Contact.Name }, + Labels = @case.Labels + .OrderBy(static assignment => EF.Functions.Collate(assignment.Label!.Name, DatabaseFunctions.CzechCollation)) + .Select(static assignment => new ExportedLabelReference { LabelId = assignment.LabelId, Name = assignment.Label!.Name }) + .ToList(), + Created = @case.Created, + Updated = @case.Updated, + }) + .SingleAsync(token); + } + + /// + /// The act's own fields, its comments and files left to the reader. + /// + public static async Task ReadExportedAct(this IQueryable acts, Guid actId, CancellationToken token) + { + return await acts + .WithId(actId) + .Select(static act => new ExportedAct + { + ActId = act.Id, + ActNumber = act.ActNumber, + ExternalActNumber = act.ExternalActNumber, + Date = act.Date, + Title = act.Title, + Description = act.Description, + Direction = act.Direction, + CaseId = act.CaseId, + Contact = act.Contact == null ? null : new ExportedContactReference { ContactId = act.Contact.Id, Name = act.Contact.Name }, + Labels = act.Labels + .OrderBy(static assignment => EF.Functions.Collate(assignment.Label!.Name, DatabaseFunctions.CzechCollation)) + .Select(static assignment => new ExportedLabelReference { LabelId = assignment.LabelId, Name = assignment.Label!.Name }) + .ToList(), + Created = act.Created, + Updated = act.Updated, + }) + .SingleAsync(token); + } + + /// + /// The join is what carries the e-mail — has no User navigation. + /// + public static IQueryable AsExportedComments(this IQueryable comments, IQueryable users) + { + return comments.Join( + users, + static comment => comment.UserId, + static user => user.Id, + static (comment, user) => new ExportedComment + { + CommentId = comment.Id, + Body = comment.Body, + AuthorEmail = user.Email, + Created = comment.Created, + Updated = comment.Updated, + }); + } + + public static IQueryable InDiaryOrder(this IQueryable comments) + { + return comments + .OrderBy(static comment => comment.Created) + .ThenBy(static comment => comment.CommentId); + } + + /// + /// The join is what carries the e-mail — has no User navigation. + /// + public static IQueryable AsExportedVersionRows(this IQueryable versions, IQueryable users) + { + return versions.Join( + users, + static version => version.UserId, + static user => user.Id, + static (version, user) => new ExportedVersionRow + { + FileVersionId = version.Id, + FileId = version.FileAssetId, + FileName = version.FileAsset!.FileName, + Number = version.Number, + SizeBytes = version.SizeBytes, + MediaType = version.MediaType, + ContentHash = version.ContentHash, + Created = version.Created, + UploaderEmail = user.Email, + StoragePath = version.StoragePath, + }); + } + + /// + /// By name, as the contact list sorts it; the name claims its document's name in this order. + /// + public static IQueryable AsExportedContacts(this IQueryable contacts) + { + return contacts + .OrderBy(static contact => EF.Functions.Collate(contact.Name, DatabaseFunctions.CzechCollation)) + .ThenBy(static contact => contact.Id) + .Select(static contact => new ExportedContact + { + ContactId = contact.Id, + Kind = contact.Kind, + Name = contact.Name, + DataBoxId = contact.DataBoxId, + Address = contact.Address, + Created = contact.Created, + Updated = contact.Updated, + }); + } + + public static IQueryable AsExportedLabels(this IQueryable