453 lines
11 KiB
TypeScript
453 lines
11 KiB
TypeScript
import { z } from 'zod'
|
|
|
|
export const MAGIC_NOTE_MAX_IMAGES = 12
|
|
export const MAGIC_NOTE_MAX_IMAGE_BYTES = 2 * 1024 * 1024
|
|
export const MAGIC_NOTE_MAX_TOTAL_IMAGE_BYTES = 8 * 1024 * 1024
|
|
export const MAGIC_NOTE_MAX_VIDEOS = 4
|
|
export const MAGIC_NOTE_MAX_VIDEO_BYTES = 16 * 1024 * 1024
|
|
export const MAGIC_NOTE_MAX_ATTACHMENTS = 8
|
|
export const MAGIC_NOTE_MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
|
|
export const MAGIC_NOTE_MAX_TOTAL_EMBED_BYTES = 32 * 1024 * 1024
|
|
export const MAGIC_NOTE_MAX_NOTE_EMBED_BYTES = 64 * 1024 * 1024
|
|
export const MAGIC_NOTE_MAX_TEXT_BYTES = 500 * 1024
|
|
|
|
export const MAGIC_NOTE_VIDEO_TYPES = [
|
|
'video/mp4',
|
|
'video/webm',
|
|
'video/ogg',
|
|
'video/quicktime'
|
|
] as const
|
|
|
|
export const MAGIC_NOTE_TEXT_COLORS = [
|
|
'#000000',
|
|
'#e60000',
|
|
'#ff9900',
|
|
'#ffff00',
|
|
'#008a00',
|
|
'#0066cc',
|
|
'#9933ff',
|
|
'#ffffff',
|
|
'#facccc',
|
|
'#ffebcc',
|
|
'#ffffcc',
|
|
'#cce8cc',
|
|
'#cce0f5',
|
|
'#ebd6ff',
|
|
'#bbbbbb',
|
|
'#f06666',
|
|
'#ffc266',
|
|
'#ffff66',
|
|
'#66b966',
|
|
'#66a3e0',
|
|
'#c285ff',
|
|
'#888888',
|
|
'#a10000',
|
|
'#b26b00',
|
|
'#b2b200',
|
|
'#006100',
|
|
'#0047b2',
|
|
'#6b24b2',
|
|
'#444444',
|
|
'#5c0000',
|
|
'#663d00',
|
|
'#666600',
|
|
'#003700',
|
|
'#002966',
|
|
'#3d1466'
|
|
] as const
|
|
|
|
export function magicNoteDataBytes(dataUrl: string): number {
|
|
const payload = dataUrl.slice(dataUrl.indexOf(',') + 1)
|
|
const padding = payload.endsWith('==')
|
|
? 2
|
|
: payload.endsWith('=')
|
|
? 1
|
|
: 0
|
|
return Math.floor((payload.length * 3) / 4) - padding
|
|
}
|
|
|
|
export const magicNoteImageDataBytes = magicNoteDataBytes
|
|
|
|
const magicNoteIdSchema = z.string().uuid()
|
|
const imageDataUrlSchema = z
|
|
.string()
|
|
.max(Math.ceil((MAGIC_NOTE_MAX_IMAGE_BYTES * 4) / 3) + 128)
|
|
.regex(
|
|
/^data:image\/(?:jpeg|png|gif|webp);base64,[A-Za-z0-9+/]+={0,2}$/,
|
|
'只支持本地 JPEG、PNG、GIF 或 WebP 图片'
|
|
)
|
|
|
|
const embeddedFileNameSchema = z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(255)
|
|
.regex(/^[^<>:"/\\|?*]+$/u, '附件名称包含不支持的字符')
|
|
.refine(
|
|
(name) =>
|
|
[...name].every((character) => {
|
|
const code = character.charCodeAt(0)
|
|
return code >= 32 && code !== 127
|
|
}),
|
|
{ message: '附件名称包含不支持的字符' }
|
|
)
|
|
.refine((name) => name !== '.' && name !== '..', {
|
|
message: '附件名称无效'
|
|
})
|
|
|
|
const embeddedMimeTypeSchema = z
|
|
.string()
|
|
.trim()
|
|
.toLowerCase()
|
|
.max(127)
|
|
.regex(
|
|
/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u,
|
|
'附件类型无效'
|
|
)
|
|
|
|
function embeddedDataUrlSchema(maxBytes: number): z.ZodString {
|
|
return z
|
|
.string()
|
|
.max(Math.ceil((maxBytes * 4) / 3) + 256)
|
|
.regex(
|
|
/^data:[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*;base64,[A-Za-z0-9+/]+={0,2}$/u,
|
|
'只支持本地附件数据'
|
|
)
|
|
}
|
|
|
|
const embeddedFileFields = {
|
|
name: embeddedFileNameSchema,
|
|
mimeType: embeddedMimeTypeSchema,
|
|
size: z.number().int().positive()
|
|
}
|
|
|
|
const localVideoSchema = z
|
|
.object({
|
|
...embeddedFileFields,
|
|
mimeType: z.enum(MAGIC_NOTE_VIDEO_TYPES),
|
|
size: z.number().int().positive().max(MAGIC_NOTE_MAX_VIDEO_BYTES),
|
|
dataUrl: embeddedDataUrlSchema(MAGIC_NOTE_MAX_VIDEO_BYTES)
|
|
})
|
|
.strict()
|
|
|
|
const attachmentSchema = z
|
|
.object({
|
|
...embeddedFileFields,
|
|
size: z.number().int().positive().max(MAGIC_NOTE_MAX_ATTACHMENT_BYTES),
|
|
dataUrl: embeddedDataUrlSchema(MAGIC_NOTE_MAX_ATTACHMENT_BYTES)
|
|
})
|
|
.strict()
|
|
|
|
const magicNoteAttributesSchema = z
|
|
.object({
|
|
bold: z.literal(true).optional(),
|
|
italic: z.literal(true).optional(),
|
|
underline: z.literal(true).optional(),
|
|
strike: z.literal(true).optional(),
|
|
code: z.literal(true).optional(),
|
|
header: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
|
|
blockquote: z.literal(true).optional(),
|
|
'code-block': z.literal(true).optional(),
|
|
list: z
|
|
.enum(['ordered', 'bullet', 'checked', 'unchecked'])
|
|
.optional(),
|
|
align: z.enum(['center', 'right', 'justify']).optional(),
|
|
indent: z.number().int().min(1).max(8).optional(),
|
|
size: z.enum(['small', 'large', 'huge']).optional(),
|
|
color: z.enum(MAGIC_NOTE_TEXT_COLORS).optional()
|
|
})
|
|
.strict()
|
|
|
|
export const magicNoteRichContentSchema = z
|
|
.object({
|
|
version: z.literal(1),
|
|
ops: z
|
|
.array(
|
|
z
|
|
.object({
|
|
insert: z.union([
|
|
z.string().max(200_000),
|
|
z.object({ image: imageDataUrlSchema }).strict(),
|
|
z.object({ localVideo: localVideoSchema }).strict(),
|
|
z.object({ attachment: attachmentSchema }).strict()
|
|
]),
|
|
attributes: magicNoteAttributesSchema.optional()
|
|
})
|
|
.strict()
|
|
)
|
|
.min(1)
|
|
.max(10_000)
|
|
})
|
|
.strict()
|
|
.superRefine((content, context) => {
|
|
const encoder = new TextEncoder()
|
|
let textBytes = 0
|
|
const imageData: string[] = []
|
|
const videoData: string[] = []
|
|
const attachmentData: string[] = []
|
|
for (const operation of content.ops) {
|
|
if (typeof operation.insert === 'string') {
|
|
textBytes += encoder.encode(operation.insert).byteLength
|
|
} else if ('image' in operation.insert) {
|
|
imageData.push(operation.insert.image)
|
|
} else if ('localVideo' in operation.insert) {
|
|
videoData.push(operation.insert.localVideo.dataUrl)
|
|
} else {
|
|
attachmentData.push(operation.insert.attachment.dataUrl)
|
|
}
|
|
}
|
|
if (textBytes > MAGIC_NOTE_MAX_TEXT_BYTES) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
message: '每条记录的文字内容不能超过 500 KB'
|
|
})
|
|
}
|
|
if (imageData.length > MAGIC_NOTE_MAX_IMAGES) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
message: `每条记录最多包含 ${MAGIC_NOTE_MAX_IMAGES} 张图片`
|
|
})
|
|
}
|
|
const estimatedBytes = imageData.reduce(
|
|
(total, dataUrl) => total + magicNoteImageDataBytes(dataUrl),
|
|
0
|
|
)
|
|
if (estimatedBytes > MAGIC_NOTE_MAX_TOTAL_IMAGE_BYTES) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
message: '一篇笔记中的图片总大小不能超过 8 MB'
|
|
})
|
|
}
|
|
if (videoData.length > MAGIC_NOTE_MAX_VIDEOS) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
message: `每条记录最多包含 ${MAGIC_NOTE_MAX_VIDEOS} 个视频`
|
|
})
|
|
}
|
|
if (attachmentData.length > MAGIC_NOTE_MAX_ATTACHMENTS) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
message: `每条记录最多包含 ${MAGIC_NOTE_MAX_ATTACHMENTS} 个附件`
|
|
})
|
|
}
|
|
const embeddedBytes = [...imageData, ...videoData, ...attachmentData].reduce(
|
|
(total, dataUrl) => total + magicNoteDataBytes(dataUrl),
|
|
0
|
|
)
|
|
if (embeddedBytes > MAGIC_NOTE_MAX_TOTAL_EMBED_BYTES) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
message: '每条记录中的图片、视频和附件总大小不能超过 32 MB'
|
|
})
|
|
}
|
|
})
|
|
|
|
export type MagicNoteRichContent = z.infer<
|
|
typeof magicNoteRichContentSchema
|
|
>
|
|
|
|
export const magicNoteCreateSchema = z
|
|
.object({
|
|
title: z.string().trim().min(1).max(100)
|
|
})
|
|
.strict()
|
|
export type MagicNoteCreateInput = z.infer<typeof magicNoteCreateSchema>
|
|
|
|
export const magicNoteUpdateSchema = z
|
|
.object({
|
|
noteId: magicNoteIdSchema,
|
|
title: z.string().trim().min(1).max(100).optional(),
|
|
pinned: z.boolean().optional(),
|
|
expectedRevision: z.number().int().nonnegative()
|
|
})
|
|
.strict()
|
|
.refine((input) => input.title !== undefined || input.pinned !== undefined, {
|
|
message: '没有可更新的笔记字段'
|
|
})
|
|
export type MagicNoteUpdateInput = z.infer<typeof magicNoteUpdateSchema>
|
|
|
|
export const magicNoteDeleteSchema = z
|
|
.object({
|
|
noteId: magicNoteIdSchema
|
|
})
|
|
.strict()
|
|
|
|
export const magicNoteEntryCreateSchema = z
|
|
.object({
|
|
noteId: magicNoteIdSchema,
|
|
content: magicNoteRichContentSchema
|
|
})
|
|
.strict()
|
|
export type MagicNoteEntryCreateInput = z.infer<
|
|
typeof magicNoteEntryCreateSchema
|
|
>
|
|
|
|
export const magicNoteEntryUpdateSchema = z
|
|
.object({
|
|
entryId: magicNoteIdSchema,
|
|
content: magicNoteRichContentSchema,
|
|
expectedRevision: z.number().int().nonnegative()
|
|
})
|
|
.strict()
|
|
export type MagicNoteEntryUpdateInput = z.infer<
|
|
typeof magicNoteEntryUpdateSchema
|
|
>
|
|
|
|
export const magicNoteEntryDeleteSchema = z
|
|
.object({
|
|
entryId: magicNoteIdSchema
|
|
})
|
|
.strict()
|
|
|
|
export const magicNoteCommentDirectionSchema = z.enum([
|
|
'general',
|
|
'expand',
|
|
'polish',
|
|
'challenge',
|
|
'brainstorm'
|
|
])
|
|
export type MagicNoteCommentDirection = z.infer<
|
|
typeof magicNoteCommentDirectionSchema
|
|
>
|
|
|
|
export const magicNoteCommentFormatSchema = z.enum([
|
|
'combined',
|
|
'narrative',
|
|
'structured'
|
|
])
|
|
export type MagicNoteCommentFormat = z.infer<
|
|
typeof magicNoteCommentFormatSchema
|
|
>
|
|
|
|
export const magicNoteAnalysisOptionsSchema = z
|
|
.object({
|
|
requestId: z.string().uuid(),
|
|
direction: magicNoteCommentDirectionSchema,
|
|
format: magicNoteCommentFormatSchema
|
|
})
|
|
.strict()
|
|
export type MagicNoteAnalysisOptions = z.infer<
|
|
typeof magicNoteAnalysisOptionsSchema
|
|
>
|
|
|
|
export const magicNoteAnalyzeSchema = z
|
|
.object({
|
|
entryId: magicNoteIdSchema,
|
|
...magicNoteAnalysisOptionsSchema.shape
|
|
})
|
|
.strict()
|
|
|
|
export const magicNoteDraftAnalyzeSchema = z
|
|
.object({
|
|
content: magicNoteRichContentSchema,
|
|
...magicNoteAnalysisOptionsSchema.shape
|
|
})
|
|
.strict()
|
|
|
|
export const magicTodoIdSchema = z
|
|
.object({
|
|
todoId: magicNoteIdSchema,
|
|
...magicNoteAnalysisOptionsSchema.shape
|
|
})
|
|
.strict()
|
|
|
|
export const magicTodoUpdateSchema = z
|
|
.object({
|
|
todoId: magicNoteIdSchema,
|
|
completed: z.boolean(),
|
|
expectedRevision: z.number().int().nonnegative()
|
|
})
|
|
.strict()
|
|
export type MagicTodoUpdateInput = z.infer<
|
|
typeof magicTodoUpdateSchema
|
|
>
|
|
|
|
export type MagicNoteCommentKind =
|
|
| 'narrative'
|
|
| 'summary'
|
|
| 'suggestion'
|
|
| 'warning'
|
|
|
|
export type MagicNoteComment = {
|
|
id: string
|
|
kind: MagicNoteCommentKind
|
|
content: string
|
|
direction?: MagicNoteCommentDirection
|
|
format?: MagicNoteCommentFormat
|
|
analyzedAt?: string
|
|
}
|
|
|
|
export type MagicNoteAnalysisStreamEvent = {
|
|
requestId: string
|
|
type: 'text'
|
|
delta: string
|
|
direction: MagicNoteCommentDirection
|
|
format: 'combined' | 'narrative'
|
|
}
|
|
|
|
export type MagicNoteEntry = {
|
|
id: string
|
|
noteId: string
|
|
content: MagicNoteRichContent
|
|
plainText: string
|
|
comments: MagicNoteComment[]
|
|
analyzedAt?: string
|
|
revision: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
export type MagicNoteSummary = {
|
|
id: string
|
|
title: string
|
|
preview: string
|
|
entryCount: number
|
|
pinned: boolean
|
|
revision: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
export type MagicNoteDetail = MagicNoteSummary & {
|
|
entries: MagicNoteEntry[]
|
|
}
|
|
|
|
export type MagicNotesSnapshot = {
|
|
notes: MagicNoteSummary[]
|
|
}
|
|
|
|
export type MagicTodoItem = {
|
|
id: string
|
|
noteId: string
|
|
entryId: string
|
|
noteTitle: string
|
|
sourceIndex: number
|
|
source: 'note'
|
|
title: string
|
|
instructions: string
|
|
completed: boolean
|
|
comments: MagicNoteComment[]
|
|
analyzedAt?: string
|
|
revision: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
export type MagicTodosSnapshot = {
|
|
todos: MagicTodoItem[]
|
|
}
|
|
|
|
export type MagicNoteDraftAnalysis = {
|
|
id: string
|
|
comments: MagicNoteComment[]
|
|
analyzedAt: string
|
|
}
|
|
|
|
export type MagicNoteSearchResult = {
|
|
noteId: string
|
|
noteTitle: string
|
|
entryId?: string
|
|
content: string
|
|
updatedAt: string
|
|
}
|