mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user