diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 138854254..2ac1e174b 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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
diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md
index f53fc3e33..af378a9d3 100644
--- a/docs/integrations/data-import.md
+++ b/docs/integrations/data-import.md
@@ -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
diff --git a/messages/de.json b/messages/de.json
index 69916f438..102a530a2 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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."
},
diff --git a/messages/en.json b/messages/en.json
index 4d70f02d0..da5a32d20 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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."
},
diff --git a/messages/es.json b/messages/es.json
index 879ccea71..4e55bf123 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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."
},
diff --git a/messages/fr.json b/messages/fr.json
index 5105b4b79..0d1b503e9 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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."
},
diff --git a/messages/it.json b/messages/it.json
index ffcbd71a8..8067e9aa9 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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."
},
diff --git a/messages/ko.json b/messages/ko.json
index 7a90ee3e6..842d0ab87 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -5795,6 +5795,7 @@
"resultSummary": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔고, {skipped}개는 건너뛰었어요.",
"resultSuccess": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔어요",
"droppedLinks": "이 계정에 같은 이름의 태그가 없어 다음 태그 없이 가져왔습니다: {keys}",
+ "keptLabels": "이 계정에는 이미 같은 키의 태그나 그룹이 자체 이름으로 있어서 그대로 유지했어요: {keys}",
"resultNothing": "불러온 게 없어요. {skipped}개 기록을 모두 건너뛰었어요.",
"resultEmpty": "그 파일에는 측정도 기분 기록도 없었어요."
},
diff --git a/messages/nl.json b/messages/nl.json
index 1d791cffe..44163c4a2 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -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."
},
diff --git a/messages/pl.json b/messages/pl.json
index 85ddb378c..5b215e72f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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."
},
diff --git a/messages/pt-BR.json b/messages/pt-BR.json
index 191340d81..60977cca7 100644
--- a/messages/pt-BR.json
+++ b/messages/pt-BR.json
@@ -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."
},
diff --git a/src/__tests__/json-import-openapi-parity.test.ts b/src/__tests__/json-import-openapi-parity.test.ts
index 7fe77d326..70e749d98 100644
--- a/src/__tests__/json-import-openapi-parity.test.ts
+++ b/src/__tests__/json-import-openapi-parity.test.ts
@@ -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!);
diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts
index ccf6fe068..7c183775f 100644
--- a/src/app/api/import/route.ts
+++ b/src/app/api/import/route.ts
@@ -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) {
diff --git a/src/components/settings/import-panel/__tests__/json-import-result.test.tsx b/src/components/settings/import-panel/__tests__/json-import-result.test.tsx
index 64825e985..fe6fbfd03 100644
--- a/src/components/settings/import-panel/__tests__/json-import-result.test.tsx
+++ b/src/components/settings/import-panel/__tests__/json-import-result.test.tsx
@@ -38,9 +38,22 @@ describe("", () => {
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("", () => {
moodEntries: 0,
skipped: 0,
droppedLinkKeys: [],
+ keptLabelKeys: [],
});
});
});
diff --git a/src/components/settings/import-panel/json-import-card.tsx b/src/components/settings/import-panel/json-import-card.tsx
index f64e6b7bc..e38c9b284 100644
--- a/src/components/settings/import-panel/json-import-card.tsx
+++ b/src/components/settings/import-panel/json-import-card.tsx
@@ -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 }) {
})}
)}
+ {result.keptLabelKeys.length > 0 && (
+
+ {t("settings.sections.export.import.json.keptLabels", {
+ keys: result.keptLabelKeys.join(", "),
+ })}
+
+ )}
>
);
}
diff --git a/src/lib/import/json/mood.ts b/src/lib/import/json/mood.ts
index e706b6b62..76cd9fbae 100644
--- a/src/lib/import/json/mood.ts
+++ b/src/lib/import/json/mood.ts
@@ -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;
}
+/**
+ * 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,
+): Promise {
+ const links = new Map();
+ 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 {
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 {
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 {
// 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 {
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;
}
}
diff --git a/src/lib/import/json/normalize.ts b/src/lib/import/json/normalize.ts
index a4ee9403b..ec309675d 100644
--- a/src/lib/import/json/normalize.ts
+++ b/src/lib/import/json/normalize.ts
@@ -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 };
}
diff --git a/src/lib/import/json/run.ts b/src/lib/import/json/run.ts
index 572b21cca..206b5b6de 100644
--- a/src/lib/import/json/run.ts
+++ b/src/lib/import/json/run.ts
@@ -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 & {
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(),
};
}
diff --git a/src/lib/import/json/schema.ts b/src/lib/import/json/schema.ts
index 7b33bdd1a..89c3d728f 100644
--- a/src/lib/import/json/schema.ts
+++ b/src/lib/import/json/schema.ts
@@ -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;
export type JsonImportMoodEntry = z.infer;
+export type JsonImportMoodTagCategory = z.infer<
+ typeof jsonImportMoodTagCategorySchema
+>;
+export type JsonImportMoodTag = z.infer;
diff --git a/src/lib/import/json/tally.ts b/src/lib/import/json/tally.ts
index 9110417a7..7627ad57b 100644
--- a/src/lib/import/json/tally.ts
+++ b/src/lib/import/json/tally.ts
@@ -11,6 +11,13 @@ export interface JsonImportTally {
*/
droppedTagKeys: Set;
droppedFactorKeys: Set;
+ /**
+ * 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;
+ keptMoodCategoryKeys: Set;
/** 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,
};
}
diff --git a/src/lib/import/json/vocabulary.ts b/src/lib/import/json/vocabulary.ts
new file mode 100644
index 000000000..761c1ef69
--- /dev/null
+++ b/src/lib/import/json/vocabulary.ts
@@ -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;
+
+/** 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();
+ private ownTags = 0;
+ private ownGroups = 0;
+
+ constructor(
+ private readonly userId: string,
+ private readonly categories: readonly JsonImportMoodTagCategory[],
+ private readonly tally: JsonImportTally,
+ ) {}
+
+ async countOwn(): Promise {
+ [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 {
+ 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 {
+ 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 {
+ 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 {
+ const { userId, entries, tally } = input;
+ const definitions = new Map(input.tags.map((tag) => [tag.key, tag]));
+ const keys = new Set(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();
+ 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;
+}
diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts
index 871fa2d04..c22baff64 100644
--- a/src/lib/openapi/routes/import.ts
+++ b/src/lib/openapi/routes/import.ts
@@ -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",
diff --git a/tests/integration/json-import-field-coverage.test.ts b/tests/integration/json-import-field-coverage.test.ts
index 69bcf01fb..c0aa14789 100644
--- a/tests/integration/json-import-field-coverage.test.ts
+++ b/tests/integration/json-import-field-coverage.test.ts
@@ -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 = {
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 = {
+ 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 = {};
+
/**
- * 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 = {
- 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 = {
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([]);
});
diff --git a/tests/integration/json-import-own-exports.test.ts b/tests/integration/json-import-own-exports.test.ts
index 50487557f..29540ce28 100644
--- a/tests/integration/json-import-own-exports.test.ts
+++ b/tests/integration/json-import-own-exports.test.ts
@@ -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 {
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(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(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" },
diff --git a/tests/integration/json-import-seed.ts b/tests/integration/json-import-seed.ts
index 5191d1b0c..c613c3114 100644
--- a/tests/integration/json-import-seed.ts
+++ b/tests/integration/json-import-seed.ts
@@ -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 {
+ // 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({