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:
Marc-André Bombeck committed 2026-10-23 22:41:36 +02:00
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!);
+8
View File
@@ -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
View File
@@ -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;
}
}
+10 -1
View File
@@ -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 };
}
+24 -2
View File
@@ -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(),
};
}
+51
View File
@@ -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>;
+9
View File
@@ -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,
};
}
+288
View File
@@ -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;
}
+80 -3
View File
@@ -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",