mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(import): bring the account's own mood vocabulary along
A full backup carries the account's own mood tags, rated factors and tag groups, and the JSON import left them out: a mood entry imported into another account landed without every link to a tag the person had made. Before the entries are written, every key they or the file name is resolved once. A catalogue tag or one the account owns is used as it is; an own tag whose label the file states differently is kept, never overwritten, and named in keptMoodTagKeys (keptMoodCategoryKeys for a group). A tag the file defines and the account lacks is created in its group, the group created the same way, within the account's caps of 50 tags and 12 groups. A key another account of the server holds comes over under a key derived from the account and the file's key, so a second import of the same file finds the copy instead of minting another. The links are written through that resolution, own rated factors included, inside the entry's transaction; anything unresolved is still dropped and named. The import card names kept keys in all nine locales. The field-coverage guard now holds the backup's customMoodTags and customMoodTagCategories to the import schema. The round trip moves a backup to another server (the same seeded catalogue, none of the account's rows), imports it into a fresh account and compares the re-export, own tags, group and rated factor included; a second case imports into another account of the same server, and a third keeps a renamed own tag.
This commit is contained in:
1 parent
d1545cf554
commit
2357807706
25 files changed
+995
-67
No files matched your search
+122
-6
@@ -33209,8 +33209,18 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/JsonImportMoodEntry"
|
||||
description: "JSON import payload: the measurements and mood entries of the JSON export or the portable full backup.
|
||||
Both sections optional; up to 10 000 rows each."
|
||||
customMoodTagCategories:
|
||||
maxItems: 10000
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/JsonImportMoodTagCategory"
|
||||
customMoodTags:
|
||||
maxItems: 10000
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/JsonImportMoodTag"
|
||||
description: "JSON import payload: the measurements and mood entries of the JSON export or the portable full backup, and
|
||||
the backup's own mood vocabulary the entries link to. Every section optional; up to 10 000 rows each."
|
||||
JsonImportMeasurement:
|
||||
type: object
|
||||
properties:
|
||||
@@ -33346,8 +33356,9 @@ components:
|
||||
type: string
|
||||
maxLength: 64
|
||||
factors:
|
||||
description: Rated factors by catalogue key; replaces the entry's rated links. A key the account cannot resolve is
|
||||
dropped and named in `droppedFactorKeys`; a rating outside the factor's own scale skips the entry.
|
||||
description: Rated factors by key (the catalogue's, or the account's own as for `structuredTags`); replaces the entry's
|
||||
rated links. A key the account cannot resolve is dropped and named in `droppedFactorKeys`; a rating outside
|
||||
the factor's own scale skips the entry.
|
||||
maxItems: 30
|
||||
type: array
|
||||
items:
|
||||
@@ -33364,8 +33375,8 @@ components:
|
||||
- key
|
||||
- rating
|
||||
structuredTags:
|
||||
description: Ticked tags by key (the catalogue or the account's own); replaces the entry's tag links. An unresolvable
|
||||
key is dropped and named in `droppedTagKeys`.
|
||||
description: Ticked tags by key (the catalogue, the account's own, or one the file's `customMoodTags` brings); replaces
|
||||
the entry's tag links. An unresolvable key is dropped and named in `droppedTagKeys`.
|
||||
maxItems: 30
|
||||
type: array
|
||||
items:
|
||||
@@ -33538,6 +33549,100 @@ components:
|
||||
- mood
|
||||
- score
|
||||
description: One mood-entry row of a JSON import file.
|
||||
JsonImportMoodTagCategory:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
description: The file's own id, read only to find the group a tag names in `categoryId`.
|
||||
type: string
|
||||
maxLength: 120
|
||||
key:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 80
|
||||
labelKey:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
icon:
|
||||
type: string
|
||||
maxLength: 60
|
||||
sortOrder:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
isActive:
|
||||
type: boolean
|
||||
label:
|
||||
description: The group's own name.
|
||||
type: string
|
||||
maxLength: 200
|
||||
labelEncrypted:
|
||||
description: A disaster-recovery file's sealed name, used only when this server's keys open it.
|
||||
type: string
|
||||
maxLength: 2000
|
||||
required:
|
||||
- key
|
||||
- labelKey
|
||||
description: "A mood-tag group the account made itself. Matched by `key`: a group the account has is used as it is, a
|
||||
missing one is created."
|
||||
JsonImportMoodTag:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 80
|
||||
labelKey:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
categoryId:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
description: The group's id in the file; a group the file does not carry falls back to the seeded `custom` group.
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- BINARY
|
||||
- RATED
|
||||
isActive:
|
||||
type: boolean
|
||||
icon:
|
||||
type: string
|
||||
maxLength: 60
|
||||
sortOrder:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
label:
|
||||
description: The tag's own name.
|
||||
type: string
|
||||
maxLength: 200
|
||||
labelEncrypted:
|
||||
description: A disaster-recovery file's sealed name, used only when this server's keys open it.
|
||||
type: string
|
||||
maxLength: 2000
|
||||
scaleMin:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 10
|
||||
scaleMax:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 10
|
||||
inverse:
|
||||
type: boolean
|
||||
required:
|
||||
- key
|
||||
- labelKey
|
||||
- categoryId
|
||||
- kind
|
||||
description: "A mood tag the account made itself, a rated factor included. Matched by `key`: the account's own tag of
|
||||
that key is used and never overwritten (a different label is named in `keptMoodTagKeys`); a missing one is
|
||||
created, within the account's custom-tag cap; a key another account of this server holds comes over under a key
|
||||
derived from the account and the file's key, so a second import of the same file finds it."
|
||||
MeasurementReminderCreate:
|
||||
type: object
|
||||
properties:
|
||||
@@ -74085,6 +74190,17 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
keptMoodTagKeys:
|
||||
description: Keys of the account's own tags the file names differently. The account's tag is kept as it is. Present only
|
||||
when non-empty.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
keptMoodCategoryKeys:
|
||||
description: The same, for the account's own tag groups.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- measurements
|
||||
- moodEntries
|
||||
|
||||
@@ -72,10 +72,17 @@ in the backup and `""` in the export, reads as absent, a backup's deletion
|
||||
records are skipped and counted, and a reading in a display unit (lb, °F,
|
||||
mmol/L) is converted to the stored one.
|
||||
|
||||
A mood entry's tags and rated factors are linked by key. A key the
|
||||
importing account has no tag for, typically a tag another account made
|
||||
for itself, is dropped from that entry, and the response names it in
|
||||
`droppedTagKeys` or `droppedFactorKeys`. The rest of the entry lands.
|
||||
A mood entry's tags and rated factors are linked by key. A full backup
|
||||
also carries the account's own tags, rated factors and tag groups, and the
|
||||
import brings them along: a tag the importing account already has is used
|
||||
as it is, and one it lacks is created (within the account's limit of 50
|
||||
custom tags and 12 groups). The account's own tag is never overwritten: when
|
||||
the file names it differently, the account's name stays and the response
|
||||
lists the key in `keptMoodTagKeys` (`keptMoodCategoryKeys` for a group).
|
||||
Moving a file between two accounts of one server works too; the tags come
|
||||
over under keys of their own. A key the account cannot resolve at all is
|
||||
dropped from that entry and named in `droppedTagKeys` or
|
||||
`droppedFactorKeys`, and the rest of the entry lands.
|
||||
|
||||
### Measurement entry
|
||||
|
||||
|
||||
@@ -5795,6 +5795,7 @@
|
||||
"resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen.",
|
||||
"resultSuccess": "{measurements} Messwerte und {moods} Stimmungseinträge importiert",
|
||||
"droppedLinks": "Ohne diese Tags importiert, weil dieses Konto keine mit dem Namen hat: {keys}",
|
||||
"keptLabels": "Dieses Konto hat Tags oder Gruppen mit diesen Schlüsseln schon unter eigenem Namen, die wurden behalten: {keys}",
|
||||
"resultNothing": "Nichts importiert. Alle {skipped} Einträge wurden übersprungen.",
|
||||
"resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge."
|
||||
},
|
||||
|
||||
@@ -5795,6 +5795,7 @@
|
||||
"resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped.",
|
||||
"resultSuccess": "Imported {measurements} measurements and {moods} mood entries",
|
||||
"droppedLinks": "Imported without these tags, because this account has none by that name: {keys}",
|
||||
"keptLabels": "This account already has tags or groups with these keys under its own names, so those were kept: {keys}",
|
||||
"resultNothing": "Nothing was imported. All {skipped} entries were skipped.",
|
||||
"resultEmpty": "That file carried no measurements and no mood entries."
|
||||
},
|
||||
|
||||
@@ -5795,6 +5795,7 @@
|
||||
"resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas.",
|
||||
"resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas",
|
||||
"droppedLinks": "Importado sin estas etiquetas, porque esta cuenta no tiene ninguna con ese nombre: {keys}",
|
||||
"keptLabels": "Esta cuenta ya tiene etiquetas o grupos con estas claves y con su propio nombre, así que se han conservado: {keys}",
|
||||
"resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.",
|
||||
"resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo."
|
||||
},
|
||||
|
||||
@@ -5795,6 +5795,7 @@
|
||||
"resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées.",
|
||||
"resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées",
|
||||
"droppedLinks": "Importé sans ces étiquettes, car ce compte n’en a aucune de ce nom : {keys}",
|
||||
"keptLabels": "Ce compte a déjà des étiquettes ou des groupes avec ces clés sous son propre nom, ils ont été conservés : {keys}",
|
||||
"resultNothing": "Rien n'a été importé. Les {skipped} entrées ont toutes été ignorées.",
|
||||
"resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur."
|
||||
},
|
||||
|
||||
@@ -5795,6 +5795,7 @@
|
||||
"resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate.",
|
||||
"resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate",
|
||||
"droppedLinks": "Importato senza questi tag, perché questo account non ne ha nessuno con quel nome: {keys}",
|
||||
"keptLabels": "Questo account ha già tag o gruppi con queste chiavi e con il proprio nome, quindi sono stati mantenuti: {keys}",
|
||||
"resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.",
|
||||
"resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore."
|
||||
},
|
||||
|
||||
@@ -5795,6 +5795,7 @@
|
||||
"resultSummary": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔고, {skipped}개는 건너뛰었어요.",
|
||||
"resultSuccess": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔어요",
|
||||
"droppedLinks": "이 계정에 같은 이름의 태그가 없어 다음 태그 없이 가져왔습니다: {keys}",
|
||||
"keptLabels": "이 계정에는 이미 같은 키의 태그나 그룹이 자체 이름으로 있어서 그대로 유지했어요: {keys}",
|
||||
"resultNothing": "불러온 게 없어요. {skipped}개 기록을 모두 건너뛰었어요.",
|
||||
"resultEmpty": "그 파일에는 측정도 기분 기록도 없었어요."
|
||||
},
|
||||
|
||||
@@ -5795,6 +5795,7 @@
|
||||
"resultSummary": "{measurements} metingen en {moods} stemmingsregistraties geïmporteerd, {skipped} overgeslagen.",
|
||||
"resultSuccess": "{measurements} metingen en {moods} stemmingsregistraties geïmporteerd",
|
||||
"droppedLinks": "Geïmporteerd zonder deze tags, omdat dit account er geen met die naam heeft: {keys}",
|
||||
"keptLabels": "Dit account heeft al tags of groepen met deze sleutels onder een eigen naam, die zijn behouden: {keys}",
|
||||
"resultNothing": "Er is niets geïmporteerd. Alle {skipped} registraties zijn overgeslagen.",
|
||||
"resultEmpty": "Dat bestand bevatte geen metingen en geen stemmingsregistraties."
|
||||
},
|
||||
|
||||
@@ -5795,6 +5795,7 @@
|
||||
"resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}.",
|
||||
"resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju",
|
||||
"droppedLinks": "Zaimportowano bez tych tagów, bo to konto nie ma żadnego o tej nazwie: {keys}",
|
||||
"keptLabels": "To konto ma już tagi lub grupy z tymi kluczami pod własną nazwą, więc zostały zachowane: {keys}",
|
||||
"resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.",
|
||||
"resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju."
|
||||
},
|
||||
|
||||
@@ -5795,6 +5795,7 @@
|
||||
"resultSummary": "Importadas {measurements} medições e {moods} registros de humor, {skipped} pulados.",
|
||||
"resultSuccess": "Importadas {measurements} medições e {moods} registros de humor",
|
||||
"droppedLinks": "Importado sem estas tags, porque esta conta não tem nenhuma com esse nome: {keys}",
|
||||
"keptLabels": "Esta conta já tem tags ou grupos com essas chaves e com nome próprio, então eles foram mantidos: {keys}",
|
||||
"resultNothing": "Nada foi importado. Todas as {skipped} entradas foram puladas.",
|
||||
"resultEmpty": "Esse arquivo não continha medições nem registros de humor."
|
||||
},
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
jsonImportMeasurementSchema,
|
||||
jsonImportMoodContextSchema,
|
||||
jsonImportMoodEntrySchema,
|
||||
jsonImportMoodTagCategorySchema,
|
||||
jsonImportMoodTagSchema,
|
||||
jsonImportSchema,
|
||||
} from "@/lib/import/json/schema";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
@@ -63,6 +66,18 @@ describe("the published JSON import contract", () => {
|
||||
expect(fields).toEqual(Object.keys(jsonImportMoodEntrySchema.shape).sort());
|
||||
});
|
||||
|
||||
it("names every section and every vocabulary field the import reads", () => {
|
||||
expect(documented("JsonImportRequest")).toEqual(
|
||||
Object.keys(jsonImportSchema.shape).sort(),
|
||||
);
|
||||
expect(documented("JsonImportMoodTag")).toEqual(
|
||||
Object.keys(jsonImportMoodTagSchema.shape).sort(),
|
||||
);
|
||||
expect(documented("JsonImportMoodTagCategory")).toEqual(
|
||||
Object.keys(jsonImportMoodTagCategorySchema.shape).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("names every day-context field the import reads, and no other", () => {
|
||||
const entry = resolve(doc.components.schemas.JsonImportMoodEntry!);
|
||||
const context = resolve(entry.properties!.context!);
|
||||
|
||||
@@ -56,10 +56,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
stats: written,
|
||||
writeFailed,
|
||||
droppedLinkKeys,
|
||||
keptMoodTagKeys,
|
||||
keptMoodCategoryKeys,
|
||||
} = await runJsonImport({
|
||||
userId: user.id,
|
||||
measurements: parsed.data.measurements,
|
||||
moodEntries: parsed.data.moodEntries,
|
||||
customMoodTagCategories: parsed.data.customMoodTagCategories,
|
||||
customMoodTags: parsed.data.customMoodTags,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
const stats = {
|
||||
@@ -68,6 +72,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// The mood links the account could not resolve, by key, only when there
|
||||
// are any: "imported" alone told the person their tags had landed.
|
||||
...droppedLinkKeysForWire(droppedLinkKeys),
|
||||
// The account's own tags and groups the file names differently: kept as
|
||||
// they are, and said so, only when there are any.
|
||||
...(keptMoodTagKeys.length > 0 ? { keptMoodTagKeys } : {}),
|
||||
...(keptMoodCategoryKeys.length > 0 ? { keptMoodCategoryKeys } : {}),
|
||||
};
|
||||
|
||||
if (writeFailed) {
|
||||
|
||||
@@ -38,9 +38,22 @@ describe("<JsonImportResultView>", () => {
|
||||
expect(html).toContain("Imported 3 measurements and 2 mood entries");
|
||||
});
|
||||
|
||||
it("names the account's own tags it kept rather than renamed", () => {
|
||||
const html = render({
|
||||
measurements: 0,
|
||||
moodEntries: 2,
|
||||
skipped: 0,
|
||||
keptMoodTagKeys: ["custom:walk"],
|
||||
keptMoodCategoryKeys: ["customcat:outdoors"],
|
||||
});
|
||||
expect(html).toContain('data-testid="import-json-kept-labels"');
|
||||
expect(html).toContain("custom:walk, customcat:outdoors");
|
||||
});
|
||||
|
||||
it("says nothing about links when every one landed", () => {
|
||||
const html = render({ measurements: 3, moodEntries: 2, skipped: 0 });
|
||||
expect(html).not.toContain("import-json-dropped-links");
|
||||
expect(html).not.toContain("import-json-kept-labels");
|
||||
});
|
||||
|
||||
it("reads a missing count as none", () => {
|
||||
@@ -49,6 +62,7 @@ describe("<JsonImportResultView>", () => {
|
||||
moodEntries: 0,
|
||||
skipped: 0,
|
||||
droppedLinkKeys: [],
|
||||
keptLabelKeys: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,11 @@ export interface JsonImportResult {
|
||||
* those links, which the count alone would not say.
|
||||
*/
|
||||
droppedLinkKeys: string[];
|
||||
/**
|
||||
* Keys of the account's own tags and groups the file names differently.
|
||||
* The account's names were kept, never overwritten.
|
||||
*/
|
||||
keptLabelKeys: string[];
|
||||
}
|
||||
|
||||
/** The route's `data`, read field by field; a missing count reads as none. */
|
||||
@@ -38,6 +43,8 @@ export function readJsonImportResult(
|
||||
skipped?: number;
|
||||
droppedTagKeys?: string[];
|
||||
droppedFactorKeys?: string[];
|
||||
keptMoodTagKeys?: string[];
|
||||
keptMoodCategoryKeys?: string[];
|
||||
} | null,
|
||||
): JsonImportResult {
|
||||
return {
|
||||
@@ -48,6 +55,10 @@ export function readJsonImportResult(
|
||||
...(data?.droppedTagKeys ?? []),
|
||||
...(data?.droppedFactorKeys ?? []),
|
||||
],
|
||||
keptLabelKeys: [
|
||||
...(data?.keptMoodTagKeys ?? []),
|
||||
...(data?.keptMoodCategoryKeys ?? []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,6 +107,16 @@ export function JsonImportResultView({ result }: { result: JsonImportResult }) {
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{result.keptLabelKeys.length > 0 && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="import-json-kept-labels"
|
||||
>
|
||||
{t("settings.sections.export.import.json.keptLabels", {
|
||||
keys: result.keptLabelKeys.join(", "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+70
-20
@@ -8,8 +8,9 @@
|
||||
* ones.
|
||||
*
|
||||
* Each entry is written with its note, its level-A values, its zone, its day
|
||||
* context and its tag and factor links, in one transaction, the way the bulk
|
||||
* mood ingest writes one: a link that throws (a rating outside its factor's
|
||||
* context and its tag and factor links (resolved through the account's
|
||||
* vocabulary, `vocabulary.ts`), in one transaction, the way the bulk mood
|
||||
* ingest writes one: a link that throws (a rating outside its factor's
|
||||
* own scale) takes the entry back with it, so a skipped row left nothing
|
||||
* behind.
|
||||
*/
|
||||
@@ -23,15 +24,12 @@ import { classifyExternalId } from "@/lib/validations/external-id";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { levelAForWrite } from "@/lib/mood/level-a";
|
||||
import { persistMoodContext } from "@/lib/mood/context";
|
||||
import {
|
||||
RatedFactorOutOfRangeError,
|
||||
replaceRatedFactorLinks,
|
||||
replaceTagLinks,
|
||||
} from "@/lib/mood/tag-links";
|
||||
import { RatedFactorOutOfRangeError } from "@/lib/mood/tag-links";
|
||||
|
||||
import { fallbackMoodDateStart } from "./mood-instant";
|
||||
import type { JsonImportMoodEntry } from "./schema";
|
||||
import type { JsonImportTally } from "./tally";
|
||||
import type { MoodLinkResolver } from "./vocabulary";
|
||||
|
||||
export type { JsonImportMoodEntry } from "./schema";
|
||||
|
||||
@@ -98,32 +96,81 @@ interface DroppedKeys {
|
||||
factors: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one kind of an entry's links with the ones the file states,
|
||||
* resolved through the import's vocabulary. A link to an archived tag stays,
|
||||
* as the edit path keeps it: it is history, not editable state. A rating
|
||||
* outside its tag's own scale throws, and the transaction takes the entry
|
||||
* back with it.
|
||||
*/
|
||||
async function replaceImportedLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
moodEntryId: string,
|
||||
kind: "RATED" | "BINARY",
|
||||
wanted: ReadonlyArray<{ key: string; rating: number | null }>,
|
||||
resolver: MoodLinkResolver,
|
||||
dropped: Set<string>,
|
||||
): Promise<void> {
|
||||
const links = new Map<string, number | null>();
|
||||
for (const { key, rating } of wanted) {
|
||||
const tag = resolver.get(key);
|
||||
if (!tag || tag.kind !== kind) {
|
||||
dropped.add(key);
|
||||
continue;
|
||||
}
|
||||
if (rating !== null && (rating < tag.scaleMin || rating > tag.scaleMax)) {
|
||||
throw new RatedFactorOutOfRangeError(
|
||||
key,
|
||||
rating,
|
||||
tag.scaleMin,
|
||||
tag.scaleMax,
|
||||
);
|
||||
}
|
||||
links.set(tag.id, rating);
|
||||
}
|
||||
await tx.moodEntryTagLink.deleteMany({
|
||||
where: { moodEntryId, moodTag: { isActive: true, kind } },
|
||||
});
|
||||
if (links.size === 0) return;
|
||||
await tx.moodEntryTagLink.createMany({
|
||||
data: [...links].map(([moodTagId, rating]) => ({
|
||||
moodEntryId,
|
||||
moodTagId,
|
||||
rating,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** The entry's links and day context, when the file states them. */
|
||||
async function writeMoodEntryParts(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
moodEntryId: string,
|
||||
e: JsonImportMoodEntry,
|
||||
resolver: MoodLinkResolver,
|
||||
dropped: DroppedKeys,
|
||||
): Promise<void> {
|
||||
await persistMoodContext(tx, moodEntryId, userId, e.context);
|
||||
if (e.factors !== undefined) {
|
||||
const keys = await replaceRatedFactorLinks(
|
||||
moodEntryId,
|
||||
userId,
|
||||
e.factors,
|
||||
await replaceImportedLinks(
|
||||
tx,
|
||||
moodEntryId,
|
||||
"RATED",
|
||||
e.factors,
|
||||
resolver,
|
||||
dropped.factors,
|
||||
);
|
||||
for (const key of keys) dropped.factors.add(key);
|
||||
}
|
||||
if (e.structuredTags !== undefined) {
|
||||
const keys = await replaceTagLinks(
|
||||
moodEntryId,
|
||||
userId,
|
||||
e.structuredTags,
|
||||
await replaceImportedLinks(
|
||||
tx,
|
||||
moodEntryId,
|
||||
"BINARY",
|
||||
e.structuredTags.map((key) => ({ key, rating: null })),
|
||||
resolver,
|
||||
dropped.tags,
|
||||
);
|
||||
for (const key of keys) dropped.tags.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +178,7 @@ async function writeMoodEntry(
|
||||
userId: string,
|
||||
e: JsonImportMoodEntry,
|
||||
loggedAt: Date,
|
||||
resolver: MoodLinkResolver,
|
||||
tally: JsonImportTally,
|
||||
): Promise<void> {
|
||||
const columns = moodEntryColumns(e, loggedAt);
|
||||
@@ -165,7 +213,7 @@ async function writeMoodEntry(
|
||||
data: { userId, ...columns, source: "IMPORT" },
|
||||
select: { id: true },
|
||||
});
|
||||
await writeMoodEntryParts(tx, userId, row.id, e, dropped);
|
||||
await writeMoodEntryParts(tx, userId, row.id, e, resolver, dropped);
|
||||
});
|
||||
for (const key of dropped.tags) tally.droppedTagKeys.add(key);
|
||||
for (const key of dropped.factors) tally.droppedFactorKeys.add(key);
|
||||
@@ -209,6 +257,7 @@ async function importMoodEntry(
|
||||
userId: string,
|
||||
e: JsonImportMoodEntry,
|
||||
fallback: FallbackState,
|
||||
resolver: MoodLinkResolver,
|
||||
tally: JsonImportTally,
|
||||
): Promise<void> {
|
||||
// Same stability floor as the measurement rows: the mood upsert keys on
|
||||
@@ -234,7 +283,7 @@ async function importMoodEntry(
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await writeMoodEntry(userId, e, loggedAt, tally);
|
||||
await writeMoodEntry(userId, e, loggedAt, resolver, tally);
|
||||
if (!e.loggedAt) {
|
||||
fallback.reserved.add(loggedAt.getTime());
|
||||
}
|
||||
@@ -281,6 +330,7 @@ async function importMoodEntry(
|
||||
export async function importJsonMoodEntries(
|
||||
userId: string,
|
||||
rows: JsonImportMoodEntry[],
|
||||
resolver: MoodLinkResolver,
|
||||
tally: JsonImportTally,
|
||||
): Promise<void> {
|
||||
const fallback: FallbackState = {
|
||||
@@ -288,7 +338,7 @@ export async function importJsonMoodEntries(
|
||||
reserved: new Set(),
|
||||
};
|
||||
for (const e of rows) {
|
||||
await importMoodEntry(userId, e, fallback, tally);
|
||||
await importMoodEntry(userId, e, fallback, resolver, tally);
|
||||
if (tally.writeFailed) break;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The JSON import reads what the app itself writes.
|
||||
*
|
||||
* Two of the app's files carry the import's two arrays: the full backup
|
||||
* Two of the app's files carry the import's arrays: the full backup
|
||||
* (`/api/export/full-backup`), at the top level, and the JSON export
|
||||
* (`/api/export?format=json`), inside the response envelope's `data`. Neither
|
||||
* was written for the import schema, and both spell an absent value the way
|
||||
@@ -104,5 +104,14 @@ export function normalizeJsonImportFile(
|
||||
file.moodEntries = read.entries;
|
||||
skipped += read.skipped;
|
||||
}
|
||||
// The mood vocabulary the entries' links point into, read the same way.
|
||||
for (const section of [
|
||||
"customMoodTagCategories",
|
||||
"customMoodTags",
|
||||
] as const) {
|
||||
if (section in source) {
|
||||
file[section] = readEntries(source[section], (entry) => entry).entries;
|
||||
}
|
||||
}
|
||||
return { file, skipped };
|
||||
}
|
||||
@@ -18,16 +18,22 @@ import {
|
||||
type JsonImportMeasurement,
|
||||
} from "./measurements";
|
||||
import { importJsonMoodEntries, type JsonImportMoodEntry } from "./mood";
|
||||
import type { JsonImportMoodTag, JsonImportMoodTagCategory } from "./schema";
|
||||
import { newJsonImportTally, type JsonImportTally } from "./tally";
|
||||
import { resolveMoodVocabulary } from "./vocabulary";
|
||||
|
||||
export async function runJsonImport(input: {
|
||||
userId: string;
|
||||
measurements: JsonImportMeasurement[] | undefined;
|
||||
moodEntries: JsonImportMoodEntry[] | undefined;
|
||||
customMoodTagCategories: JsonImportMoodTagCategory[] | undefined;
|
||||
customMoodTags: JsonImportMoodTag[] | undefined;
|
||||
ipAddress: string | null;
|
||||
}): Promise<
|
||||
Pick<JsonImportTally, "stats" | "writeFailed"> & {
|
||||
droppedLinkKeys: DroppedLinkKeys;
|
||||
keptMoodTagKeys: string[];
|
||||
keptMoodCategoryKeys: string[];
|
||||
}
|
||||
> {
|
||||
const { userId } = input;
|
||||
@@ -49,8 +55,22 @@ export async function runJsonImport(input: {
|
||||
.map((row) => row.measuredAt),
|
||||
).catch(() => {});
|
||||
|
||||
if (!tally.writeFailed && input.moodEntries?.length) {
|
||||
await importJsonMoodEntries(userId, input.moodEntries, tally);
|
||||
// The account's own vocabulary goes in before the entries that link to
|
||||
// it, and every link key is resolved once.
|
||||
const entries = input.moodEntries ?? [];
|
||||
const resolver =
|
||||
!tally.writeFailed &&
|
||||
(entries.length > 0 || (input.customMoodTags?.length ?? 0) > 0)
|
||||
? await resolveMoodVocabulary({
|
||||
userId,
|
||||
entries,
|
||||
categories: input.customMoodTagCategories ?? [],
|
||||
tags: input.customMoodTags ?? [],
|
||||
tally,
|
||||
})
|
||||
: new Map();
|
||||
if (!tally.writeFailed && entries.length > 0) {
|
||||
await importJsonMoodEntries(userId, entries, resolver, tally);
|
||||
}
|
||||
|
||||
// One bounded re-fold per import is cheaper than firing N per-row hooks.
|
||||
@@ -111,5 +131,7 @@ export async function runJsonImport(input: {
|
||||
droppedTagKeys: [...tally.droppedTagKeys].sort(),
|
||||
droppedFactorKeys: [...tally.droppedFactorKeys].sort(),
|
||||
},
|
||||
keptMoodTagKeys: [...tally.keptMoodTagKeys].sort(),
|
||||
keptMoodCategoryKeys: [...tally.keptMoodCategoryKeys].sort(),
|
||||
};
|
||||
}
|
||||
@@ -174,6 +174,44 @@ export const jsonImportMoodEntrySchema = z
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A mood-tag group the account made itself, as the full backup writes it.
|
||||
* `id` is the file's own id, read only to find the group a tag of the file
|
||||
* names; the group is matched and created by `key`.
|
||||
*/
|
||||
export const jsonImportMoodTagCategorySchema = z.object({
|
||||
id: z.string().max(120).optional(),
|
||||
key: z.string().min(1).max(80),
|
||||
labelKey: z.string().min(1).max(120),
|
||||
icon: z.string().max(60).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
// The person's own name for the group: readable in a portable file,
|
||||
// ciphertext in a disaster-recovery one (opened only by the host's keys).
|
||||
label: z.string().max(200).optional(),
|
||||
labelEncrypted: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A mood tag the account made itself, a rated factor included. Matched and
|
||||
* created by `key`; its group is the file's `categoryId`, resolved through
|
||||
* the groups the file carries.
|
||||
*/
|
||||
export const jsonImportMoodTagSchema = z.object({
|
||||
key: z.string().min(1).max(80),
|
||||
labelKey: z.string().min(1).max(120),
|
||||
categoryId: z.string().min(1).max(120),
|
||||
kind: z.enum(["BINARY", "RATED"]),
|
||||
isActive: z.boolean().optional(),
|
||||
icon: z.string().max(60).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
label: z.string().max(200).optional(),
|
||||
labelEncrypted: z.string().max(2000).optional(),
|
||||
scaleMin: z.number().int().min(0).max(10).optional(),
|
||||
scaleMax: z.number().int().min(0).max(10).optional(),
|
||||
inverse: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const jsonImportSchema = z.object({
|
||||
measurements: z
|
||||
.array(jsonImportMeasurementSchema)
|
||||
@@ -183,7 +221,20 @@ export const jsonImportSchema = z.object({
|
||||
.array(jsonImportMoodEntrySchema)
|
||||
.max(JSON_IMPORT_MAX_ROWS)
|
||||
.optional(),
|
||||
// The account's own mood vocabulary, which the entries' links point into.
|
||||
customMoodTagCategories: z
|
||||
.array(jsonImportMoodTagCategorySchema)
|
||||
.max(JSON_IMPORT_MAX_ROWS)
|
||||
.optional(),
|
||||
customMoodTags: z
|
||||
.array(jsonImportMoodTagSchema)
|
||||
.max(JSON_IMPORT_MAX_ROWS)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type JsonImportMeasurement = z.infer<typeof jsonImportMeasurementSchema>;
|
||||
export type JsonImportMoodEntry = z.infer<typeof jsonImportMoodEntrySchema>;
|
||||
export type JsonImportMoodTagCategory = z.infer<
|
||||
typeof jsonImportMoodTagCategorySchema
|
||||
>;
|
||||
export type JsonImportMoodTag = z.infer<typeof jsonImportMoodTagSchema>;
|
||||
@@ -11,6 +11,13 @@ export interface JsonImportTally {
|
||||
*/
|
||||
droppedTagKeys: Set<string>;
|
||||
droppedFactorKeys: Set<string>;
|
||||
/**
|
||||
* The account's own tags and groups whose key the file shares and whose
|
||||
* label it states differently. The account's version is kept, never
|
||||
* overwritten; the key is named.
|
||||
*/
|
||||
keptMoodTagKeys: Set<string>;
|
||||
keptMoodCategoryKeys: Set<string>;
|
||||
/** A write failed for a reason other than a duplicate; the import stops. */
|
||||
writeFailed: boolean;
|
||||
}
|
||||
@@ -21,6 +28,8 @@ export function newJsonImportTally(): JsonImportTally {
|
||||
unstableShapes: [],
|
||||
droppedTagKeys: new Set(),
|
||||
droppedFactorKeys: new Set(),
|
||||
keptMoodTagKeys: new Set(),
|
||||
keptMoodCategoryKeys: new Set(),
|
||||
writeFailed: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* The account's own mood vocabulary, brought along by a JSON import.
|
||||
*
|
||||
* A mood entry links to tags by key: the seeded catalogue's, and the ones the
|
||||
* person made (custom tags, their own rated factors, the groups they sit in).
|
||||
* A full backup carries the account's own as `customMoodTags` and
|
||||
* `customMoodTagCategories`. Without them an entry imported into another
|
||||
* account landed without every link to a tag the person had made.
|
||||
*
|
||||
* So before the entries are written, each key an entry or the file names is
|
||||
* resolved once, to the tag the importing account links to:
|
||||
*
|
||||
* - a catalogue tag, or a tag the account already owns, is used as it is.
|
||||
* An own tag whose label differs from the file's is kept, never
|
||||
* overwritten, and named (`keptMoodTagKeys`): the account's name for it
|
||||
* is the one it uses now;
|
||||
* - a tag the file defines and the account lacks is created, under its own
|
||||
* key, in its group (created the same way when missing);
|
||||
* - a key another account on this instance holds (keys are unique per
|
||||
* instance, so a file moved between two accounts of one server collides)
|
||||
* comes over under a key derived from the account and the file's key, so
|
||||
* importing the same file twice finds the copy rather than making a
|
||||
* second one;
|
||||
* - anything else (a key neither the catalogue nor the file knows, the
|
||||
* wrong kind for its link, a tag past the account's custom-tag cap) is
|
||||
* dropped from the entries and named.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sealForRestore } from "@/lib/export/sealed-text";
|
||||
import {
|
||||
CUSTOM_CATEGORY_KEY_PREFIX,
|
||||
CUSTOM_TAG_KEY_PREFIX,
|
||||
decryptCustomLabel,
|
||||
MAX_CUSTOM_GROUPS_PER_USER,
|
||||
MAX_CUSTOM_TAGS_PER_USER,
|
||||
} from "@/lib/mood/custom-tags";
|
||||
|
||||
import type {
|
||||
JsonImportMoodEntry,
|
||||
JsonImportMoodTag,
|
||||
JsonImportMoodTagCategory,
|
||||
} from "./schema";
|
||||
import type { JsonImportTally } from "./tally";
|
||||
|
||||
/** A tag an imported link may point at. */
|
||||
export interface ImportLinkTag {
|
||||
id: string;
|
||||
kind: string;
|
||||
scaleMin: number;
|
||||
scaleMax: number;
|
||||
}
|
||||
|
||||
/** The file's keys, resolved to the importing account's tags. */
|
||||
export type MoodLinkResolver = ReadonlyMap<string, ImportLinkTag>;
|
||||
|
||||
/** The seeded group a tag lands in when the file does not carry its own. */
|
||||
const FALLBACK_CATEGORY_KEY = "custom";
|
||||
|
||||
const LINK_TAG_SELECT = {
|
||||
id: true,
|
||||
key: true,
|
||||
kind: true,
|
||||
scaleMin: true,
|
||||
scaleMax: true,
|
||||
userId: true,
|
||||
labelEncrypted: true,
|
||||
} as const satisfies Prisma.MoodTagSelect;
|
||||
|
||||
/**
|
||||
* The key a copy of `fileKey` carries in `userId`'s account when another
|
||||
* account holds `fileKey`: a fresh-looking custom key, the same on every
|
||||
* import of the same file into the same account.
|
||||
*/
|
||||
export function derivedVocabularyKey(
|
||||
prefix: string,
|
||||
userId: string,
|
||||
fileKey: string,
|
||||
): string {
|
||||
const hex = createHash("sha256")
|
||||
.update(`${userId}\u0000${fileKey}`)
|
||||
.digest("hex");
|
||||
return `${prefix}${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
/** The file's readable label, or the one its ciphertext opens to here. */
|
||||
function fileLabel(item: {
|
||||
label?: string;
|
||||
labelEncrypted?: string;
|
||||
}): string | null {
|
||||
if (item.label !== undefined) return item.label;
|
||||
return item.labelEncrypted ? decryptCustomLabel(item.labelEncrypted) : null;
|
||||
}
|
||||
|
||||
/** The label column for a row written from `item`, sealed under this host's key. */
|
||||
function sealedLabel(
|
||||
item: { label?: string; labelEncrypted?: string },
|
||||
path: string,
|
||||
): string | null {
|
||||
return sealForRestore(item.labelEncrypted, item.label, path, []);
|
||||
}
|
||||
|
||||
class Vocabulary {
|
||||
private readonly categoryIds = new Map<string, string | null>();
|
||||
private ownTags = 0;
|
||||
private ownGroups = 0;
|
||||
|
||||
constructor(
|
||||
private readonly userId: string,
|
||||
private readonly categories: readonly JsonImportMoodTagCategory[],
|
||||
private readonly tally: JsonImportTally,
|
||||
) {}
|
||||
|
||||
async countOwn(): Promise<void> {
|
||||
[this.ownTags, this.ownGroups] = await Promise.all([
|
||||
prisma.moodTag.count({ where: { userId: this.userId, isActive: true } }),
|
||||
prisma.moodTagCategory.count({ where: { userId: this.userId } }),
|
||||
]);
|
||||
}
|
||||
|
||||
/** The importing account's group for a tag of the file, or null. */
|
||||
async category(fileCategoryId: string): Promise<string | null> {
|
||||
const cached = this.categoryIds.get(fileCategoryId);
|
||||
if (cached !== undefined) return cached;
|
||||
const resolved = await this.resolveCategory(fileCategoryId);
|
||||
this.categoryIds.set(fileCategoryId, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private async resolveCategory(
|
||||
fileCategoryId: string,
|
||||
): Promise<string | null> {
|
||||
const own = this.categories.find((c) => c.id === fileCategoryId);
|
||||
if (!own) {
|
||||
// A seeded group of the instance the file came from. Its id is this
|
||||
// instance's only when the file came from here; otherwise the tag goes
|
||||
// to the seeded group made for custom tags.
|
||||
const seeded =
|
||||
(await prisma.moodTagCategory.findFirst({
|
||||
where: { userId: null, id: fileCategoryId },
|
||||
select: { id: true },
|
||||
})) ??
|
||||
(await prisma.moodTagCategory.findFirst({
|
||||
where: { userId: null, key: FALLBACK_CATEGORY_KEY },
|
||||
select: { id: true },
|
||||
}));
|
||||
return seeded?.id ?? null;
|
||||
}
|
||||
const existing = await prisma.moodTagCategory.findUnique({
|
||||
where: { key: own.key },
|
||||
select: { id: true, userId: true, labelEncrypted: true },
|
||||
});
|
||||
if (
|
||||
existing &&
|
||||
(existing.userId === null || existing.userId === this.userId)
|
||||
) {
|
||||
const label = fileLabel(own);
|
||||
if (
|
||||
existing.userId === this.userId &&
|
||||
label !== null &&
|
||||
decryptCustomLabel(existing.labelEncrypted) !== label
|
||||
) {
|
||||
this.tally.keptMoodCategoryKeys.add(own.key);
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
const key = existing
|
||||
? derivedVocabularyKey(CUSTOM_CATEGORY_KEY_PREFIX, this.userId, own.key)
|
||||
: own.key;
|
||||
const copy = await prisma.moodTagCategory.findUnique({
|
||||
where: { key },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
if (copy) return copy.userId === this.userId ? copy.id : null;
|
||||
if (this.ownGroups >= MAX_CUSTOM_GROUPS_PER_USER) return null;
|
||||
const created = await prisma.moodTagCategory.create({
|
||||
data: {
|
||||
userId: this.userId,
|
||||
key,
|
||||
labelKey: own.labelKey,
|
||||
icon: own.icon ?? null,
|
||||
sortOrder: own.sortOrder ?? 0,
|
||||
isActive: own.isActive ?? true,
|
||||
labelEncrypted: sealedLabel(own, `customMoodTagCategories.${own.key}`),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
this.ownGroups += 1;
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Create `tag` in the importing account under `key`; null when it cannot be. */
|
||||
async create(
|
||||
tag: JsonImportMoodTag,
|
||||
key: string,
|
||||
): Promise<ImportLinkTag | null> {
|
||||
const active = tag.isActive ?? true;
|
||||
if (active && this.ownTags >= MAX_CUSTOM_TAGS_PER_USER) return null;
|
||||
const categoryId = await this.category(tag.categoryId);
|
||||
if (categoryId === null) return null;
|
||||
const created = await prisma.moodTag.create({
|
||||
data: {
|
||||
userId: this.userId,
|
||||
key,
|
||||
labelKey: tag.labelKey,
|
||||
categoryId,
|
||||
kind: tag.kind,
|
||||
isActive: active,
|
||||
icon: tag.icon ?? null,
|
||||
sortOrder: tag.sortOrder ?? 0,
|
||||
labelEncrypted: sealedLabel(tag, `customMoodTags.${tag.key}`),
|
||||
scaleMin: tag.scaleMin ?? 1,
|
||||
scaleMax: tag.scaleMax ?? 5,
|
||||
inverse: tag.inverse ?? false,
|
||||
},
|
||||
select: LINK_TAG_SELECT,
|
||||
});
|
||||
if (active) this.ownTags += 1;
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every key the file's entries link to, creating the account's own
|
||||
* tags and groups the file brings and the account lacks. Keys that resolve
|
||||
* to nothing are named on the tally by the link writer, which knows which
|
||||
* kind of link asked for them.
|
||||
*/
|
||||
export async function resolveMoodVocabulary(input: {
|
||||
userId: string;
|
||||
entries: readonly JsonImportMoodEntry[];
|
||||
categories: readonly JsonImportMoodTagCategory[];
|
||||
tags: readonly JsonImportMoodTag[];
|
||||
tally: JsonImportTally;
|
||||
}): Promise<MoodLinkResolver> {
|
||||
const { userId, entries, tally } = input;
|
||||
const definitions = new Map(input.tags.map((tag) => [tag.key, tag]));
|
||||
const keys = new Set<string>(definitions.keys());
|
||||
for (const entry of entries) {
|
||||
for (const factor of entry.factors ?? []) keys.add(factor.key);
|
||||
for (const key of entry.structuredTags ?? []) keys.add(key);
|
||||
}
|
||||
const resolved = new Map<string, ImportLinkTag>();
|
||||
if (keys.size === 0) return resolved;
|
||||
|
||||
const derived = new Map(
|
||||
[...definitions.keys()].map((key) => [
|
||||
key,
|
||||
derivedVocabularyKey(CUSTOM_TAG_KEY_PREFIX, userId, key),
|
||||
]),
|
||||
);
|
||||
const rows = await prisma.moodTag.findMany({
|
||||
where: { key: { in: [...keys, ...derived.values()] } },
|
||||
select: LINK_TAG_SELECT,
|
||||
});
|
||||
const byKey = new Map(rows.map((row) => [row.key, row]));
|
||||
const vocabulary = new Vocabulary(userId, input.categories, tally);
|
||||
await vocabulary.countOwn();
|
||||
|
||||
for (const key of keys) {
|
||||
const definition = definitions.get(key);
|
||||
const row = byKey.get(key);
|
||||
if (row && (row.userId === null || row.userId === userId)) {
|
||||
const label = definition ? fileLabel(definition) : null;
|
||||
if (
|
||||
row.userId === userId &&
|
||||
label !== null &&
|
||||
decryptCustomLabel(row.labelEncrypted) !== label
|
||||
) {
|
||||
tally.keptMoodTagKeys.add(key);
|
||||
}
|
||||
resolved.set(key, row);
|
||||
continue;
|
||||
}
|
||||
if (!definition) continue;
|
||||
const target = row ? derived.get(key)! : key;
|
||||
const copy = row ? byKey.get(target) : undefined;
|
||||
if (copy) {
|
||||
if (copy.userId === userId) resolved.set(key, copy);
|
||||
continue;
|
||||
}
|
||||
const created = await vocabulary.create(definition, target);
|
||||
if (created) resolved.set(key, created);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -228,14 +228,14 @@ const jsonImportMoodEntry = z
|
||||
.max(30)
|
||||
.optional()
|
||||
.describe(
|
||||
"Rated factors by catalogue key; replaces the entry's rated links. A key the account cannot resolve is dropped and named in `droppedFactorKeys`; a rating outside the factor's own scale skips the entry.",
|
||||
"Rated factors by key (the catalogue's, or the account's own as for `structuredTags`); replaces the entry's rated links. A key the account cannot resolve is dropped and named in `droppedFactorKeys`; a rating outside the factor's own scale skips the entry.",
|
||||
),
|
||||
structuredTags: z
|
||||
.array(z.string().max(60))
|
||||
.max(30)
|
||||
.optional()
|
||||
.describe(
|
||||
"Ticked tags by key (the catalogue or the account's own); replaces the entry's tag links. An unresolvable key is dropped and named in `droppedTagKeys`.",
|
||||
"Ticked tags by key (the catalogue, the account's own, or one the file's `customMoodTags` brings); replaces the entry's tag links. An unresolvable key is dropped and named in `droppedTagKeys`.",
|
||||
),
|
||||
context: moodContextWire
|
||||
.partial()
|
||||
@@ -257,15 +257,82 @@ const jsonImportMoodEntry = z
|
||||
description: "One mood-entry row of a JSON import file.",
|
||||
});
|
||||
|
||||
const jsonImportMoodTagCategory = z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.max(120)
|
||||
.optional()
|
||||
.describe(
|
||||
"The file's own id, read only to find the group a tag names in `categoryId`.",
|
||||
),
|
||||
key: z.string().min(1).max(80),
|
||||
labelKey: z.string().min(1).max(120),
|
||||
icon: z.string().max(60).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
label: z.string().max(200).optional().describe("The group's own name."),
|
||||
labelEncrypted: z
|
||||
.string()
|
||||
.max(2000)
|
||||
.optional()
|
||||
.describe(
|
||||
"A disaster-recovery file's sealed name, used only when this server's keys open it.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "JsonImportMoodTagCategory",
|
||||
description:
|
||||
"A mood-tag group the account made itself. Matched by `key`: a group the account has is used as it is, a missing one is created.",
|
||||
});
|
||||
|
||||
const jsonImportMoodTag = z
|
||||
.object({
|
||||
key: z.string().min(1).max(80),
|
||||
labelKey: z.string().min(1).max(120),
|
||||
categoryId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.describe(
|
||||
"The group's id in the file; a group the file does not carry falls back to the seeded `custom` group.",
|
||||
),
|
||||
kind: z.enum(["BINARY", "RATED"]),
|
||||
isActive: z.boolean().optional(),
|
||||
icon: z.string().max(60).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
label: z.string().max(200).optional().describe("The tag's own name."),
|
||||
labelEncrypted: z
|
||||
.string()
|
||||
.max(2000)
|
||||
.optional()
|
||||
.describe(
|
||||
"A disaster-recovery file's sealed name, used only when this server's keys open it.",
|
||||
),
|
||||
scaleMin: z.number().int().min(0).max(10).optional(),
|
||||
scaleMax: z.number().int().min(0).max(10).optional(),
|
||||
inverse: z.boolean().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "JsonImportMoodTag",
|
||||
description:
|
||||
"A mood tag the account made itself, a rated factor included. Matched by `key`: the account's own tag of that key is used and never overwritten (a different label is named in `keptMoodTagKeys`); a missing one is created, within the account's custom-tag cap; a key another account of this server holds comes over under a key derived from the account and the file's key, so a second import of the same file finds it.",
|
||||
});
|
||||
|
||||
const jsonImportRequest = z
|
||||
.object({
|
||||
measurements: z.array(jsonImportMeasurement).max(10000).optional(),
|
||||
moodEntries: z.array(jsonImportMoodEntry).max(10000).optional(),
|
||||
customMoodTagCategories: z
|
||||
.array(jsonImportMoodTagCategory)
|
||||
.max(10000)
|
||||
.optional(),
|
||||
customMoodTags: z.array(jsonImportMoodTag).max(10000).optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "JsonImportRequest",
|
||||
description:
|
||||
"JSON import payload: the measurements and mood entries of the JSON export or the portable full backup. Both sections optional; up to 10 000 rows each.",
|
||||
"JSON import payload: the measurements and mood entries of the JSON export or the portable full backup, and the backup's own mood vocabulary the entries link to. Every section optional; up to 10 000 rows each.",
|
||||
});
|
||||
|
||||
const jsonImportStats = z
|
||||
@@ -299,6 +366,16 @@ const jsonImportStats = z
|
||||
.describe(
|
||||
"Rated-factor keys this account cannot resolve, as `droppedTagKeys`.",
|
||||
),
|
||||
keptMoodTagKeys: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"Keys of the account's own tags the file names differently. The account's tag is kept as it is. Present only when non-empty.",
|
||||
),
|
||||
keptMoodCategoryKeys: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("The same, for the account's own tag groups."),
|
||||
})
|
||||
.meta({
|
||||
id: "JsonImportStats",
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
jsonImportMeasurementSchema,
|
||||
jsonImportMoodContextSchema,
|
||||
jsonImportMoodEntrySchema,
|
||||
jsonImportMoodTagCategorySchema,
|
||||
jsonImportMoodTagSchema,
|
||||
} from "@/lib/import/json/schema";
|
||||
|
||||
import { callRoute } from "../kit/call-route";
|
||||
@@ -86,20 +88,26 @@ const CONTEXT_NOT_IMPORTED: Record<string, string> = {
|
||||
updatedAt: ROW_STAMP,
|
||||
};
|
||||
|
||||
/** A tag or group of the file is matched and created by key, not by id. */
|
||||
const MOOD_TAG_NOT_IMPORTED: Record<string, string> = {
|
||||
id: "A tag is matched and created by its key; its id in the file names a row of the account it came from (disaster-recovery file only).",
|
||||
};
|
||||
const MOOD_CATEGORY_NOT_IMPORTED: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* The files' other sections. The JSON import carries readings and mood
|
||||
* entries into an account that has its own record already; an account's
|
||||
* whole record (medications, labs, documents, settings, …) comes back through
|
||||
* the backup restore, which replaces it. The mood vocabulary is named apart
|
||||
* because the entries' links point into it: a link to a tag the importing
|
||||
* account does not have is dropped and named in the response.
|
||||
* The files' other sections. The JSON import carries readings, mood entries
|
||||
* and the account's own mood vocabulary (which the entries' links point into)
|
||||
* into an account that has its own record already; an account's whole record
|
||||
* (medications, labs, documents, settings, …) comes back through the backup
|
||||
* restore, which replaces it.
|
||||
*/
|
||||
const IMPORTED_SECTIONS = new Set(["measurements", "moodEntries"]);
|
||||
const VOCABULARY_SECTIONS: Record<string, string> = {
|
||||
customMoodTags:
|
||||
"Account vocabulary, restored by the backup restore. An imported link to one of these tags resolves only when the importing account has a tag of that key, and is otherwise dropped and named (`droppedTagKeys`).",
|
||||
customMoodTagCategories:
|
||||
"Account vocabulary, restored by the backup restore with the tags it groups.",
|
||||
const IMPORTED_SECTIONS = new Set([
|
||||
"measurements",
|
||||
"moodEntries",
|
||||
"customMoodTags",
|
||||
"customMoodTagCategories",
|
||||
]);
|
||||
const NAMED_MOOD_SECTIONS: Record<string, string> = {
|
||||
hiddenMoodTags:
|
||||
"An account setting (which tags its editor hides), restored by the backup restore.",
|
||||
};
|
||||
@@ -166,6 +174,18 @@ const MEASUREMENT_READ = new Set(
|
||||
);
|
||||
const MOOD_READ = new Set(Object.keys(jsonImportMoodEntrySchema.shape));
|
||||
const CONTEXT_READ = new Set(Object.keys(jsonImportMoodContextSchema.shape));
|
||||
const MOOD_TAG_READ = new Set(Object.keys(jsonImportMoodTagSchema.shape));
|
||||
const MOOD_CATEGORY_READ = new Set(
|
||||
Object.keys(jsonImportMoodTagCategorySchema.shape),
|
||||
);
|
||||
const BACKUP_NAMES = ["portable", "disasterRecovery"] as const;
|
||||
|
||||
function sectionOf(
|
||||
name: (typeof BACKUP_NAMES)[number],
|
||||
section: "customMoodTags" | "customMoodTagCategories",
|
||||
): Row[] {
|
||||
return files[name][section] as Row[];
|
||||
}
|
||||
|
||||
const FILE_NAMES = ["jsonExport", "portable", "disasterRecovery"] as const;
|
||||
|
||||
@@ -207,13 +227,37 @@ describe("the JSON import reads what the exporters write", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("names the mood vocabulary the entries' links point into", () => {
|
||||
for (const name of ["portable", "disasterRecovery"] as const) {
|
||||
it.each(BACKUP_NAMES)("every field of the %s's own mood tags", (name) => {
|
||||
const rows = sectionOf(name, "customMoodTags");
|
||||
// The seed's own tag and its own rated factor.
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2);
|
||||
expect(
|
||||
unaccounted(keysOf(rows), MOOD_TAG_READ, MOOD_TAG_NOT_IMPORTED),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(BACKUP_NAMES)("every field of the %s's own mood groups", (name) => {
|
||||
const rows = sectionOf(name, "customMoodTagCategories");
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
unaccounted(keysOf(rows), MOOD_CATEGORY_READ, MOOD_CATEGORY_NOT_IMPORTED),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads or names every mood section the backups carry", () => {
|
||||
for (const name of BACKUP_NAMES) {
|
||||
const sections = Object.keys(files[name]);
|
||||
expect(sections).toEqual(expect.arrayContaining([...IMPORTED_SECTIONS]));
|
||||
for (const section of Object.keys(VOCABULARY_SECTIONS)) {
|
||||
for (const section of Object.keys(NAMED_MOOD_SECTIONS)) {
|
||||
expect(sections, `${name} carries ${section}`).toContain(section);
|
||||
}
|
||||
// Every mood section of the file is one of the two lists.
|
||||
const mood = sections.filter((s) => /mood/i.test(s));
|
||||
expect(
|
||||
mood.filter(
|
||||
(s) => !IMPORTED_SECTIONS.has(s) && !(s in NAMED_MOOD_SECTIONS),
|
||||
),
|
||||
).toEqual([]);
|
||||
}
|
||||
// The JSON export's other two sections are medications, restored the
|
||||
// same way as every other section of an account's record.
|
||||
@@ -245,6 +289,14 @@ describe("the JSON import reads what the exporters write", () => {
|
||||
...Object.keys(CONTEXT_NOT_IMPORTED)
|
||||
.filter((key) => !writtenContext.has(key))
|
||||
.map((key) => `context.${key}`),
|
||||
...Object.keys(MOOD_TAG_NOT_IMPORTED)
|
||||
.filter(
|
||||
(key) =>
|
||||
!BACKUP_NAMES.some((name) =>
|
||||
keysOf(sectionOf(name, "customMoodTags")).has(key),
|
||||
),
|
||||
)
|
||||
.map((key) => `customMoodTag.${key}`),
|
||||
];
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
@@ -256,6 +308,7 @@ describe("the JSON import reads what the exporters write", () => {
|
||||
),
|
||||
...Object.keys(MOOD_NOT_IMPORTED).filter((k) => MOOD_READ.has(k)),
|
||||
...Object.keys(CONTEXT_NOT_IMPORTED).filter((k) => CONTEXT_READ.has(k)),
|
||||
...Object.keys(MOOD_TAG_NOT_IMPORTED).filter((k) => MOOD_TAG_READ.has(k)),
|
||||
];
|
||||
expect(both).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -29,11 +29,14 @@ import { POST as importJson } from "@/app/api/import/route";
|
||||
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import { derivedVocabularyKey } from "@/lib/import/json/vocabulary";
|
||||
import { decryptCustomLabel, encryptCustomLabel } from "@/lib/mood/custom-tags";
|
||||
|
||||
import { callRoute } from "../kit/call-route";
|
||||
import { makeSession, makeUser } from "../kit/factories";
|
||||
import {
|
||||
JI_OWN_TAG_KEY,
|
||||
JI_OWN_TAG_LABEL,
|
||||
seedJsonImportCatalogue,
|
||||
seedJsonImportOwner,
|
||||
} from "./json-import-seed";
|
||||
@@ -49,14 +52,19 @@ interface ImportStats {
|
||||
skipped: number;
|
||||
droppedTagKeys?: string[];
|
||||
droppedFactorKeys?: string[];
|
||||
keptMoodTagKeys?: string[];
|
||||
}
|
||||
|
||||
let ownerId: string;
|
||||
|
||||
async function fullBackupOf(userId: string): Promise<{
|
||||
interface FullBackup {
|
||||
measurements: Row[];
|
||||
moodEntries: Row[];
|
||||
}> {
|
||||
customMoodTags: Row[];
|
||||
customMoodTagCategories: Row[];
|
||||
}
|
||||
|
||||
async function fullBackupOf(userId: string): Promise<FullBackup> {
|
||||
let text = "";
|
||||
await streamFullBackupJson(prisma, userId, (chunk) => {
|
||||
text += chunk;
|
||||
@@ -89,6 +97,35 @@ async function importInto(file: unknown) {
|
||||
return { userId: user.id, res };
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's own vocabulary, comparable across accounts and servers: each tag
|
||||
* with its group named by key rather than by a row id.
|
||||
*/
|
||||
function vocabularyOf(file: FullBackup) {
|
||||
const groupKey = new Map(
|
||||
file.customMoodTagCategories.map((c) => [c.id as string, c.key as string]),
|
||||
);
|
||||
const groups = new Map(
|
||||
file.customMoodTagCategories.map((c) => {
|
||||
const { id: _id, ...rest } = c;
|
||||
return [c.key as string, rest];
|
||||
}),
|
||||
);
|
||||
return file.customMoodTags
|
||||
.map((tag) => {
|
||||
const { categoryId, ...rest } = tag;
|
||||
const categoryKey =
|
||||
groupKey.get(categoryId as string) ?? (categoryId as string);
|
||||
return {
|
||||
...rest,
|
||||
key: tag.key as string,
|
||||
categoryKey,
|
||||
group: groups.get(categoryKey) ?? null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => String(a.key).localeCompare(String(b.key)));
|
||||
}
|
||||
|
||||
/** A row without the fields the import deliberately does not carry. */
|
||||
function comparable(row: Row): Row {
|
||||
const { id: _id, source: _source, ...rest } = row;
|
||||
@@ -112,7 +149,7 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe("the JSON import takes the app's own files back", () => {
|
||||
it("takes a full backup back field for field", async () => {
|
||||
it("takes a full backup into another account of the same server, every link included", async () => {
|
||||
const original = await fullBackupOf(ownerId);
|
||||
// The file carries the shapes the import used to refuse or drop.
|
||||
expect(original.measurements.some((m) => m.notes === null)).toBe(true);
|
||||
@@ -123,33 +160,100 @@ describe("the JSON import takes the app's own files back", () => {
|
||||
|
||||
const { userId, res } = await importInto(original);
|
||||
expect(res.status).toBe(200);
|
||||
// Every link lands: the owner's own tags come over as copies, under keys
|
||||
// of the importing account, because this server's keys are the owner's.
|
||||
expect(res.data).toEqual({
|
||||
measurements: original.measurements.length,
|
||||
moodEntries: original.moodEntries.length,
|
||||
skipped: 0,
|
||||
// The account's own tag is not this account's: the entry lands without
|
||||
// that one link, and the response says which.
|
||||
droppedTagKeys: [JI_OWN_TAG_KEY],
|
||||
});
|
||||
const copyOf = (key: string) =>
|
||||
key.startsWith("custom:")
|
||||
? derivedVocabularyKey("custom:", userId, key)
|
||||
: key.startsWith("customcat:")
|
||||
? derivedVocabularyKey("customcat:", userId, key)
|
||||
: key;
|
||||
|
||||
const back = await fullBackupOf(userId);
|
||||
const expectedMeasurements = original.measurements
|
||||
.map(comparable)
|
||||
.sort(byInstant("measuredAt"));
|
||||
expect(
|
||||
back.measurements.map(comparable).sort(byInstant("measuredAt")),
|
||||
).toEqual(expectedMeasurements);
|
||||
).toEqual(
|
||||
original.measurements.map(comparable).sort(byInstant("measuredAt")),
|
||||
);
|
||||
const expectedMood = original.moodEntries
|
||||
.map((entry) => ({
|
||||
...comparable(entry),
|
||||
structuredTags: (entry.structuredTags as string[]).filter(
|
||||
(key) => key !== JI_OWN_TAG_KEY,
|
||||
structuredTags: (entry.structuredTags as string[]).map(copyOf),
|
||||
factors: (entry.factors as Array<{ key: string; rating: number }>).map(
|
||||
(factor) => ({ ...factor, key: copyOf(factor.key) }),
|
||||
),
|
||||
}))
|
||||
.sort(byInstant("loggedAt"));
|
||||
expect(
|
||||
back.moodEntries.map(comparable).sort(byInstant("loggedAt")),
|
||||
).toEqual(expectedMood);
|
||||
expect(vocabularyOf(back)).toEqual(
|
||||
vocabularyOf(original)
|
||||
.map((tag) => ({
|
||||
...tag,
|
||||
key: copyOf(tag.key),
|
||||
categoryKey: copyOf(tag.categoryKey),
|
||||
group: tag.group && {
|
||||
...tag.group,
|
||||
key: copyOf(String(tag.group.key)),
|
||||
},
|
||||
}))
|
||||
.sort((a, b) => a.key.localeCompare(b.key)),
|
||||
);
|
||||
|
||||
// A second import of the same file finds the copies.
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const again = await callRoute<ImportStats>(importJson, {
|
||||
method: "POST",
|
||||
route: "/api/import",
|
||||
body: original,
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(again.status).toBe(200);
|
||||
expect(await prisma.moodTag.count({ where: { userId } })).toBe(
|
||||
original.customMoodTags.length,
|
||||
);
|
||||
expect(await prisma.moodTagCategory.count({ where: { userId } })).toBe(
|
||||
original.customMoodTagCategories.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the account's own name for a tag the file names differently, and says so", async () => {
|
||||
const original = await fullBackupOf(ownerId);
|
||||
// The owner renames the tag after the backup, then imports the backup.
|
||||
await prisma.moodTag.update({
|
||||
where: { key: JI_OWN_TAG_KEY },
|
||||
data: { labelEncrypted: encryptCustomLabel("Evening walk") },
|
||||
});
|
||||
const owner = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: ownerId },
|
||||
});
|
||||
// The vocabulary alone: the readings of a portable file carry no source
|
||||
// id, and would be written a second time into the account they came from.
|
||||
const res = await callRoute<ImportStats>(importJson, {
|
||||
method: "POST",
|
||||
route: "/api/import",
|
||||
body: {
|
||||
customMoodTags: original.customMoodTags,
|
||||
customMoodTagCategories: original.customMoodTagCategories,
|
||||
},
|
||||
auth: await makeSession(owner),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.keptMoodTagKeys).toEqual([JI_OWN_TAG_KEY]);
|
||||
const tag = await prisma.moodTag.findUniqueOrThrow({
|
||||
where: { key: JI_OWN_TAG_KEY },
|
||||
});
|
||||
expect(decryptCustomLabel(tag.labelEncrypted)).toBe("Evening walk");
|
||||
await prisma.moodTag.update({
|
||||
where: { key: JI_OWN_TAG_KEY },
|
||||
data: { labelEncrypted: encryptCustomLabel(JI_OWN_TAG_LABEL) },
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the JSON export back, empty strings and its envelope included", async () => {
|
||||
@@ -245,6 +349,40 @@ describe("the JSON import takes the app's own files back", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a full backup moved to another server", () => {
|
||||
it("comes back equal, the account's own tags, groups and rated factor included", async () => {
|
||||
const original = await fullBackupOf(ownerId);
|
||||
expect(original.customMoodTags.length).toBeGreaterThanOrEqual(2);
|
||||
expect(original.customMoodTags.some((tag) => tag.kind === "RATED")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(original.customMoodTagCategories).toHaveLength(1);
|
||||
|
||||
// Another server: the same seeded catalogue, none of this account's rows.
|
||||
await truncateAllTables(prisma);
|
||||
await seedJsonImportCatalogue(prisma);
|
||||
|
||||
const { userId, res } = await importInto(original);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({
|
||||
measurements: original.measurements.length,
|
||||
moodEntries: original.moodEntries.length,
|
||||
skipped: 0,
|
||||
});
|
||||
|
||||
const back = await fullBackupOf(userId);
|
||||
expect(
|
||||
back.measurements.map(comparable).sort(byInstant("measuredAt")),
|
||||
).toEqual(
|
||||
original.measurements.map(comparable).sort(byInstant("measuredAt")),
|
||||
);
|
||||
expect(
|
||||
back.moodEntries.map(comparable).sort(byInstant("loggedAt")),
|
||||
).toEqual(original.moodEntries.map(comparable).sort(byInstant("loggedAt")));
|
||||
expect(vocabularyOf(back)).toEqual(vocabularyOf(original));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the CSV import reads the measurement CSV export", () => {
|
||||
const READINGS = [
|
||||
{ type: "WEIGHT", value: 81.4, unit: "kg", at: "2026-09-10T06:30:00.000Z" },
|
||||
|
||||
@@ -5,21 +5,27 @@
|
||||
* Every reading carries what makes it that reading: a range keeps its bounds,
|
||||
* a sleep night its stages and its writer, a rhythm alert its verdict, a
|
||||
* glucose value its meal-time context, a note its text. The mood entry
|
||||
* carries a note, all five level-A values, its zone, a rated factor, a
|
||||
* catalogue tag, a tag of the account's own and a full day context. A field
|
||||
* carries a note, all five level-A values, its zone, a catalogue factor and
|
||||
* tag, the account's own tag and its own rated factor (in a group of its own)
|
||||
* and a full day context. A field
|
||||
* the seed leaves empty is a field neither test can see arrive, so nothing
|
||||
* here is left at its default.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { encodeKeyList } from "@/lib/mood/context-vocabulary";
|
||||
import { encryptCustomLabel } from "@/lib/mood/custom-tags";
|
||||
|
||||
import { makeUser } from "../kit/factories";
|
||||
|
||||
export const JI_FACTOR_KEY = "ji_sleep_quality";
|
||||
export const JI_TAG_KEY = "ji_headache";
|
||||
/** The account's own tag: another account importing the file cannot resolve it. */
|
||||
/** The account's own tag, in a seeded group. */
|
||||
export const JI_OWN_TAG_KEY = "custom:ji_long_walk";
|
||||
/** The account's own group, and its own rated factor in it. */
|
||||
export const JI_OWN_CATEGORY_KEY = "customcat:ji_outdoors";
|
||||
export const JI_OWN_FACTOR_KEY = "custom:ji_trail_feel";
|
||||
export const JI_OWN_TAG_LABEL = "Long walk";
|
||||
|
||||
export const JI_MEASUREMENTS = [
|
||||
{
|
||||
@@ -119,11 +125,19 @@ export interface SeededOwner {
|
||||
export async function seedJsonImportCatalogue(
|
||||
prisma: PrismaClient,
|
||||
): Promise<void> {
|
||||
// Fixed ids, as the migrations seed the real catalogue (whose `custom`
|
||||
// group the test database holds too): a group's id is the same on every
|
||||
// server, which is what lets an imported tag find it.
|
||||
const category = await prisma.moodTagCategory.create({
|
||||
data: { key: "ji_category", labelKey: "mood.tagCategory.ji" },
|
||||
data: {
|
||||
id: "mtc_ji_category",
|
||||
key: "ji_category",
|
||||
labelKey: "mood.tagCategory.ji",
|
||||
},
|
||||
});
|
||||
await prisma.moodTag.create({
|
||||
data: {
|
||||
id: "mt_ji_headache",
|
||||
categoryId: category.id,
|
||||
key: JI_TAG_KEY,
|
||||
labelKey: "mood.tag.jiHeadache",
|
||||
@@ -132,6 +146,7 @@ export async function seedJsonImportCatalogue(
|
||||
});
|
||||
await prisma.moodTag.create({
|
||||
data: {
|
||||
id: "mt_ji_sleep_quality",
|
||||
categoryId: category.id,
|
||||
key: JI_FACTOR_KEY,
|
||||
labelKey: "mood.tag.jiSleepQuality",
|
||||
@@ -195,6 +210,31 @@ export async function seedJsonImportOwner(
|
||||
key: JI_OWN_TAG_KEY,
|
||||
labelKey: "mood.tag.custom",
|
||||
kind: "BINARY",
|
||||
labelEncrypted: encryptCustomLabel(JI_OWN_TAG_LABEL),
|
||||
},
|
||||
});
|
||||
const ownCategory = await prisma.moodTagCategory.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
key: JI_OWN_CATEGORY_KEY,
|
||||
labelKey: "mood.tagCategory.custom",
|
||||
icon: null,
|
||||
sortOrder: 7,
|
||||
labelEncrypted: encryptCustomLabel("Outdoors"),
|
||||
},
|
||||
});
|
||||
const ownFactor = await prisma.moodTag.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
categoryId: ownCategory.id,
|
||||
key: JI_OWN_FACTOR_KEY,
|
||||
labelKey: "mood.tag.custom",
|
||||
kind: "RATED",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: true,
|
||||
sortOrder: 2,
|
||||
labelEncrypted: encryptCustomLabel("How the trail felt"),
|
||||
},
|
||||
});
|
||||
const [tag, factor] = await Promise.all([
|
||||
@@ -226,6 +266,7 @@ export async function seedJsonImportOwner(
|
||||
{ moodEntryId: entry.id, moodTagId: tag.id },
|
||||
{ moodEntryId: entry.id, moodTagId: ownTag.id },
|
||||
{ moodEntryId: entry.id, moodTagId: factor.id, rating: 4 },
|
||||
{ moodEntryId: entry.id, moodTagId: ownFactor.id, rating: 2 },
|
||||
],
|
||||
});
|
||||
await prisma.moodContext.create({
|
||||
|
||||
Reference in new issue
Block a user