import { Notes, Memoriets } from "../../../../models/index.js"; import { MAX_NOTE_TEXT_LENGTH } from "../../../../const.js"; import { noteVisibilities } from "../../../../types.js"; import { ApiError } from "../../error.js"; import define from "../../define.js"; import { HOUR } from "../../../../const.js"; import { createNoteFromApiData } from "../../../../services/note/create-from-api.js"; import { genId } from "../../../../misc/gen-id.js"; export const meta = { tags: [ "memoriet" ], requireCredential: true, limit: { duration: HOUR, max: 300 }, kind: "write:notes", res: { type: "object", optional: false, nullable: false, properties: { memoriet: { type: "object", optional: false, nullable: false, properties: { id: { type: "string", optional: false, nullable: false }, expiresAt: { type: "string", optional: false, nullable: true }, textLayers: { type: "array", optional: false, nullable: false, items: { type: "object", optional: false, nullable: false } }, note: { type: "object", optional: false, nullable: false, ref: "Note" } } } } }, errors: { cannotExpireToPast: { message: "Expiration time must be in the future.", code: "CANNOT_EXPIRE_TO_PAST", id: "4b86af67-c80f-46ef-a166-440c4e3cf83a" } } }; export const paramDef = { type: "object", properties: { visibility: { type: "string", enum: noteVisibilities, default: "home" }, visibleUserIds: { type: "array", uniqueItems: true, items: { type: "string", format: "misskey:id" } }, text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true }, cw: { type: "string", nullable: true, maxLength: 100 }, fileIds: { type: "array", uniqueItems: true, minItems: 1, maxItems: 16, items: { type: "string", format: "misskey:id" } }, textLayers: { type: "array", maxItems: 32, default: [], items: { type: "object", properties: { id: { type: "string", maxLength: 64 }, text: { type: "string", minLength: 1, maxLength: 200 }, color: { type: "string", maxLength: 32 }, backgroundColor: { type: "string", maxLength: 32, nullable: true }, backgroundWidth: { type: "number", minimum: 0, maximum: 100, nullable: true }, backgroundHeight: { type: "number", minimum: 0, maximum: 100, nullable: true }, fontSize: { type: "number", minimum: 8, maximum: 96 }, x: { type: "number", minimum: 0, maximum: 100 }, y: { type: "number", minimum: 0, maximum: 100 }, rotate: { type: "number", minimum: -180, maximum: 180 } }, required: [ "text" ], additionalProperties: false } }, expiresAt: { type: "integer", nullable: true } }, anyOf: [ { properties: { text: { type: "string", minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false } }, required: [ "text" ] }, { required: [ "fileIds" ] } ] }; const colorPattern = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i; function clamp(value, min, max, fallback) { const num = typeof value === "number" && Number.isFinite(value) ? value : fallback; return Math.min(max, Math.max(min, num)); } function normalizeColor(value, fallback) { return typeof value === "string" && colorPattern.test(value) ? value : fallback; } function normalizeTextLayers(layers) { if (!Array.isArray(layers)) return []; return layers.slice(0, 32).flatMap((layer, index)=>{ if (layer == null || typeof layer !== "object") return []; const input = layer; const text = typeof input.text === "string" ? input.text.trim().slice(0, 200) : ""; if (text.length === 0) return []; const backgroundColor = input.backgroundColor == null || input.backgroundColor === "transparent" ? null : normalizeColor(input.backgroundColor, "#000000cc"); return [ { id: typeof input.id === "string" && input.id.length > 0 ? input.id.slice(0, 64) : genId(), text, color: normalizeColor(input.color, "#ffffff"), backgroundColor, backgroundWidth: input.backgroundWidth == null ? null : clamp(input.backgroundWidth, 0, 100, 0), backgroundHeight: input.backgroundHeight == null ? null : clamp(input.backgroundHeight, 0, 100, 0), fontSize: clamp(input.fontSize, 8, 96, 28), x: clamp(input.x, 0, 100, 50), y: clamp(input.y, 0, 100, 50 + index * 6), rotate: clamp(input.rotate, -180, 180, 0) } ]; }); } export default define(meta, paramDef, async (ps, user)=>{ const expiresAt = typeof ps.expiresAt === "number" ? new Date(ps.expiresAt) : null; if (expiresAt != null && expiresAt.getTime() <= Date.now()) { throw new ApiError(meta.errors.cannotExpireToPast); } const textLayers = normalizeTextLayers(ps.textLayers); const note = await createNoteFromApiData(user, { text: ps.text ? `${ps.text.trim()}\n#Memoriet` : "#Memoriet", cw: ps.cw, fileIds: ps.fileIds, visibility: ps.visibility, visibleUserIds: ps.visibleUserIds, localOnly: true }, new Date()); const memoriet = await Memoriets.save({ id: genId(), createdAt: new Date(), userId: user.id, noteId: note.id, expiresAt, textLayers }); return { memoriet: { id: memoriet.id, expiresAt: memoriet.expiresAt?.toISOString() ?? null, textLayers: memoriet.textLayers, note: await Notes.pack(note, user) } }; });