fix:第一次提交,当前还有很多BUG
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@graduation/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"sql.js": "^1.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.10.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const REMBG_COMMAND = process.env.REMBG_COMMAND ?? 'rembg'
|
||||
const REMBG_TIMEOUT_MS = Number(process.env.REMBG_TIMEOUT_MS ?? 120000)
|
||||
|
||||
export class BackgroundRemovalError extends Error {
|
||||
constructor(message: string, readonly statusCode = 500) {
|
||||
super(message)
|
||||
this.name = 'BackgroundRemovalError'
|
||||
}
|
||||
}
|
||||
|
||||
export function removeImageBackground(input: Buffer) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
const errorChunks: Buffer[] = []
|
||||
const child = spawn(REMBG_COMMAND, ['i'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGTERM')
|
||||
reject(new BackgroundRemovalError('背景移除超时,请稍后重试', 504))
|
||||
}, REMBG_TIMEOUT_MS)
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk)
|
||||
})
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
errorChunks.push(chunk)
|
||||
})
|
||||
|
||||
child.on('error', (error: NodeJS.ErrnoException) => {
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (error.code === 'ENOENT') {
|
||||
reject(new BackgroundRemovalError('服务器未安装 rembg,请先安装后再使用背景移除功能', 503))
|
||||
return
|
||||
}
|
||||
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (code !== 0) {
|
||||
const stderr = Buffer.concat(errorChunks).toString('utf8').trim()
|
||||
reject(new BackgroundRemovalError(stderr || `rembg 处理失败,退出码 ${code}`))
|
||||
return
|
||||
}
|
||||
|
||||
const output = Buffer.concat(chunks)
|
||||
|
||||
if (output.length === 0) {
|
||||
reject(new BackgroundRemovalError('rembg 未返回图片结果'))
|
||||
return
|
||||
}
|
||||
|
||||
resolve(output)
|
||||
})
|
||||
|
||||
child.stdin.end(input)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import express from 'express'
|
||||
|
||||
import { BackgroundRemovalError, removeImageBackground } from './background-removal.js'
|
||||
import { readMediaFile } from './media-storage.js'
|
||||
import { loadAppState, saveAppState } from './storage.js'
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 4000)
|
||||
const app = express()
|
||||
|
||||
app.use((_request, response, next) => {
|
||||
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
|
||||
response.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
|
||||
response.setHeader('Cross-Origin-Resource-Policy', 'same-origin')
|
||||
next()
|
||||
})
|
||||
|
||||
app.use(express.json({ limit: '100mb' }))
|
||||
|
||||
app.get('/api/health', (_request, response) => {
|
||||
response.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post(
|
||||
'/api/background-removal',
|
||||
express.raw({
|
||||
limit: '25mb',
|
||||
type: ['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream']
|
||||
}),
|
||||
async (request, response, next) => {
|
||||
try {
|
||||
if (!Buffer.isBuffer(request.body) || request.body.length === 0) {
|
||||
response.status(400).json({ error: '请上传需要处理的图片' })
|
||||
return
|
||||
}
|
||||
|
||||
const output = await removeImageBackground(request.body)
|
||||
|
||||
response.setHeader('Cache-Control', 'no-store')
|
||||
response.setHeader('Content-Type', 'image/png')
|
||||
response.send(output)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
app.get('/api/storage', async (_request, response, next) => {
|
||||
try {
|
||||
response.json(await loadAppState())
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/storage', async (request, response, next) => {
|
||||
try {
|
||||
await saveAppState(request.body)
|
||||
response.json({ ok: true })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/storage/media/*path', (request, response) => {
|
||||
const pathParam = request.params.path
|
||||
const segments = Array.isArray(pathParam) ? pathParam : String(pathParam).split('/')
|
||||
const media = readMediaFile(segments)
|
||||
|
||||
if (!media) {
|
||||
response.status(404).send('Not found')
|
||||
return
|
||||
}
|
||||
|
||||
response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
response.setHeader('Content-Type', media.contentType)
|
||||
response.send(media.bytes)
|
||||
})
|
||||
|
||||
app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
|
||||
console.error(error)
|
||||
response.status(error instanceof BackgroundRemovalError ? error.statusCode : 500).json({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
})
|
||||
})
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Graduation API listening on http://127.0.0.1:${PORT}`)
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { ASSET_DIR, MEDIA_DIR } from './paths.js'
|
||||
|
||||
const MEDIA_URL_PREFIX = '/api/storage/media'
|
||||
|
||||
const MIME_EXTENSIONS: Record<string, 'jpg' | 'png'> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png'
|
||||
}
|
||||
|
||||
const EXTENSION_CONTENT_TYPES: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png'
|
||||
}
|
||||
|
||||
type MediaFile = {
|
||||
bytes: Buffer
|
||||
contentType: string
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function saveDataUrl(dataUrl: string) {
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
return dataUrl
|
||||
}
|
||||
|
||||
const declaredContentType = match[1]
|
||||
const bytes = Buffer.from(match[2], 'base64')
|
||||
const sniffedExtension =
|
||||
bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff
|
||||
? 'jpg'
|
||||
: bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47
|
||||
? 'png'
|
||||
: undefined
|
||||
const extension = MIME_EXTENSIONS[declaredContentType] ?? sniffedExtension
|
||||
|
||||
if (!extension) {
|
||||
return dataUrl
|
||||
}
|
||||
|
||||
const hash = createHash('sha256').update(bytes).digest('hex')
|
||||
const fileName = `${hash}.${extension}`
|
||||
const filePath = path.join(ASSET_DIR, fileName)
|
||||
|
||||
mkdirSync(ASSET_DIR, { recursive: true })
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
writeFileSync(filePath, bytes)
|
||||
}
|
||||
|
||||
return `${MEDIA_URL_PREFIX}/assets/${fileName}`
|
||||
}
|
||||
|
||||
export function externalizeMedia(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return value.startsWith('data:') ? saveDataUrl(value) : value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => externalizeMedia(item))
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
if (key === 'blob' || key === 'jpg') {
|
||||
continue
|
||||
}
|
||||
|
||||
result[key] = externalizeMedia(nestedValue)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function readMediaFile(segments: string[]): MediaFile | null {
|
||||
if (
|
||||
segments.length === 0 ||
|
||||
segments.some((segment) => !segment || segment === '.' || segment === '..')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const requestedPath = path.join(MEDIA_DIR, ...segments)
|
||||
const relativePath = path.relative(MEDIA_DIR, requestedPath)
|
||||
|
||||
if (relativePath.startsWith('..') || path.isAbsolute(relativePath) || !existsSync(requestedPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const contentType = EXTENSION_CONTENT_TYPES[path.extname(requestedPath).toLowerCase()]
|
||||
|
||||
if (!contentType) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
bytes: readFileSync(requestedPath),
|
||||
contentType
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export const WORKSPACE_ROOT = path.resolve(process.cwd(), '../..')
|
||||
export const DATA_DIR = path.join(WORKSPACE_ROOT, 'data')
|
||||
export const DB_PATH = path.join(DATA_DIR, 'graduation.sqlite')
|
||||
export const MEDIA_DIR = path.join(DATA_DIR, 'media')
|
||||
export const ASSET_DIR = path.join(MEDIA_DIR, 'assets')
|
||||
export const SQL_WASM_PATH = path.join(WORKSPACE_ROOT, 'node_modules', 'sql.js', 'dist')
|
||||
@@ -0,0 +1,492 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
|
||||
import { externalizeMedia } from './media-storage.js'
|
||||
import { DATA_DIR, DB_PATH, SQL_WASM_PATH } from './paths.js'
|
||||
|
||||
type PersistedAppState = {
|
||||
templates: Array<{
|
||||
id: string
|
||||
name: string
|
||||
image: unknown
|
||||
config: unknown
|
||||
updatedAt: string
|
||||
}>
|
||||
activeTemplateId: string
|
||||
classes: Array<{
|
||||
id: string
|
||||
name: string
|
||||
students: Array<{
|
||||
id: string
|
||||
name: string
|
||||
photo?: unknown
|
||||
photoFileName?: string
|
||||
idPhoto?: unknown
|
||||
idPhotoFileName?: string
|
||||
idPhotoCrop?: unknown
|
||||
}>
|
||||
idPhotoConfig?: unknown
|
||||
createdAt: string
|
||||
}>
|
||||
activeClassId: string
|
||||
certificates: Array<{
|
||||
id: string
|
||||
classId: string
|
||||
className: string
|
||||
studentId: string
|
||||
studentName: string
|
||||
photo: unknown
|
||||
template: PersistedAppState['templates'][number]
|
||||
asset: {
|
||||
fileName: string
|
||||
}
|
||||
jpgUrl?: string
|
||||
updatedAt: string
|
||||
}>
|
||||
}
|
||||
|
||||
type SqlJsStatement = {
|
||||
run(values?: unknown[]): void
|
||||
free(): void
|
||||
}
|
||||
|
||||
type SqlJsDatabase = {
|
||||
run(sql: string): void
|
||||
exec(sql: string): Array<{ columns: string[]; values: unknown[][] }>
|
||||
prepare(sql: string): SqlJsStatement
|
||||
export(): Uint8Array
|
||||
}
|
||||
|
||||
type SqlJsStatic = {
|
||||
Database: new (data?: Uint8Array | Buffer) => SqlJsDatabase
|
||||
}
|
||||
|
||||
let databasePromise: Promise<SqlJsDatabase> | undefined
|
||||
|
||||
const EMPTY_STATE: PersistedAppState = {
|
||||
templates: [],
|
||||
activeTemplateId: '',
|
||||
classes: [],
|
||||
activeClassId: '',
|
||||
certificates: []
|
||||
}
|
||||
|
||||
function asString(value: unknown) {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function asNumber(value: unknown) {
|
||||
return typeof value === 'number' ? value : 0
|
||||
}
|
||||
|
||||
function json<T>(value: string, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(value) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function rows<T extends Record<string, unknown>>(database: SqlJsDatabase, sql: string): T[] {
|
||||
const result = database.exec(sql)[0]
|
||||
|
||||
if (!result) {
|
||||
return []
|
||||
}
|
||||
|
||||
return result.values.map((valueRow) =>
|
||||
Object.fromEntries(result.columns.map((column, index) => [column, valueRow[index]]))
|
||||
) as T[]
|
||||
}
|
||||
|
||||
function scalar(database: SqlJsDatabase, sql: string) {
|
||||
return database.exec(sql)[0]?.values[0]?.[0]
|
||||
}
|
||||
|
||||
function persist(database: SqlJsDatabase) {
|
||||
writeFileSync(DB_PATH, Buffer.from(database.export()))
|
||||
}
|
||||
|
||||
async function db() {
|
||||
if (!databasePromise) {
|
||||
databasePromise = (async () => {
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
const runtimeRequire = createRequire(import.meta.url)
|
||||
const initSqlJs = runtimeRequire('sql.js') as (options?: {
|
||||
locateFile?: (file: string) => string
|
||||
}) => Promise<SqlJsStatic>
|
||||
const SQL = await initSqlJs({
|
||||
locateFile: (file) => path.join(SQL_WASM_PATH, file)
|
||||
})
|
||||
const database = existsSync(DB_PATH)
|
||||
? new SQL.Database(readFileSync(DB_PATH))
|
||||
: new SQL.Database()
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS app_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
image_json TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS classes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
id_photo_config_json TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id TEXT PRIMARY KEY,
|
||||
class_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
photo_json TEXT,
|
||||
photo_file_name TEXT,
|
||||
id_photo_json TEXT,
|
||||
id_photo_file_name TEXT,
|
||||
id_photo_crop_json TEXT,
|
||||
sort_order INTEGER NOT NULL,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
const classColumns = rows<{ name: string }>(database, 'PRAGMA table_info(classes)').map((column) => asString(column.name))
|
||||
const studentColumns = rows<{ name: string }>(database, 'PRAGMA table_info(students)').map((column) => asString(column.name))
|
||||
|
||||
if (!classColumns.includes('id_photo_config_json')) {
|
||||
database.run('ALTER TABLE classes ADD COLUMN id_photo_config_json TEXT')
|
||||
}
|
||||
|
||||
if (!studentColumns.includes('id_photo_json')) {
|
||||
database.run('ALTER TABLE students ADD COLUMN id_photo_json TEXT')
|
||||
}
|
||||
|
||||
if (!studentColumns.includes('id_photo_file_name')) {
|
||||
database.run('ALTER TABLE students ADD COLUMN id_photo_file_name TEXT')
|
||||
}
|
||||
|
||||
if (!studentColumns.includes('id_photo_crop_json')) {
|
||||
database.run('ALTER TABLE students ADD COLUMN id_photo_crop_json TEXT')
|
||||
}
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS generated_certificates (
|
||||
id TEXT PRIMARY KEY,
|
||||
class_id TEXT NOT NULL,
|
||||
student_id TEXT NOT NULL,
|
||||
template_id TEXT,
|
||||
class_name TEXT NOT NULL,
|
||||
student_name TEXT NOT NULL,
|
||||
asset_file_name TEXT NOT NULL,
|
||||
jpg_url TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE SET NULL
|
||||
);
|
||||
`)
|
||||
|
||||
persist(database)
|
||||
return database
|
||||
})()
|
||||
}
|
||||
|
||||
return databasePromise
|
||||
}
|
||||
|
||||
function runPrepared(
|
||||
database: SqlJsDatabase,
|
||||
sql: string,
|
||||
run: (statement: SqlJsStatement) => void
|
||||
) {
|
||||
const statement = database.prepare(sql)
|
||||
|
||||
try {
|
||||
run(statement)
|
||||
} finally {
|
||||
statement.free()
|
||||
}
|
||||
}
|
||||
|
||||
function hasStructuredState(database: SqlJsDatabase) {
|
||||
const [{ count = 0 } = { count: 0 }] = rows<{ count: number }>(
|
||||
database,
|
||||
`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM templates) +
|
||||
(SELECT COUNT(*) FROM classes) +
|
||||
(SELECT COUNT(*) FROM students) +
|
||||
(SELECT COUNT(*) FROM generated_certificates) AS count
|
||||
`
|
||||
)
|
||||
|
||||
return count > 0
|
||||
}
|
||||
|
||||
export async function loadAppState(): Promise<PersistedAppState> {
|
||||
const database = await db()
|
||||
const value = scalar(database, 'SELECT json FROM app_state WHERE id = 1 LIMIT 1')
|
||||
const savedState = json(asString(value), EMPTY_STATE)
|
||||
|
||||
if (!hasStructuredState(database)) {
|
||||
if (
|
||||
savedState.templates?.length ||
|
||||
savedState.classes?.length ||
|
||||
savedState.certificates?.length
|
||||
) {
|
||||
await saveAppState(savedState)
|
||||
return loadStructuredState(database, savedState)
|
||||
}
|
||||
|
||||
return {
|
||||
...EMPTY_STATE,
|
||||
activeTemplateId: savedState.activeTemplateId ?? '',
|
||||
activeClassId: savedState.activeClassId ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
const structuredState = loadStructuredState(database, savedState)
|
||||
const normalizedState = externalizeMedia(structuredState) as PersistedAppState
|
||||
|
||||
if (JSON.stringify(normalizedState) !== JSON.stringify(structuredState)) {
|
||||
await saveAppState(normalizedState)
|
||||
}
|
||||
|
||||
return normalizedState
|
||||
}
|
||||
|
||||
export async function saveAppState(state: PersistedAppState): Promise<void> {
|
||||
const database = await db()
|
||||
const previousAppStateBytes = asNumber(
|
||||
scalar(database, 'SELECT length(json) FROM app_state WHERE id = 1 LIMIT 1')
|
||||
)
|
||||
const normalizedState = externalizeMedia(state) as PersistedAppState
|
||||
const updatedAt = new Date().toISOString()
|
||||
const activeState = {
|
||||
activeTemplateId: normalizedState.activeTemplateId,
|
||||
activeClassId: normalizedState.activeClassId
|
||||
}
|
||||
|
||||
try {
|
||||
database.run('BEGIN TRANSACTION')
|
||||
database.run('DELETE FROM generated_certificates')
|
||||
database.run('DELETE FROM students')
|
||||
database.run('DELETE FROM classes')
|
||||
database.run('DELETE FROM templates')
|
||||
|
||||
runPrepared(database, 'INSERT INTO templates (id, name, image_json, config_json, updated_at) VALUES (?, ?, ?, ?, ?)', (statement) => {
|
||||
for (const template of normalizedState.templates) {
|
||||
statement.run([
|
||||
template.id,
|
||||
template.name,
|
||||
JSON.stringify(template.image),
|
||||
JSON.stringify(template.config),
|
||||
template.updatedAt
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
runPrepared(database, 'INSERT INTO classes (id, name, id_photo_config_json, created_at) VALUES (?, ?, ?, ?)', (statement) => {
|
||||
for (const classItem of normalizedState.classes) {
|
||||
statement.run([
|
||||
classItem.id,
|
||||
classItem.name,
|
||||
classItem.idPhotoConfig ? JSON.stringify(classItem.idPhotoConfig) : null,
|
||||
classItem.createdAt
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
runPrepared(
|
||||
database,
|
||||
'INSERT INTO students (id, class_id, name, photo_json, photo_file_name, id_photo_json, id_photo_file_name, id_photo_crop_json, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
(statement) => {
|
||||
for (const classItem of normalizedState.classes) {
|
||||
classItem.students.forEach((student, index) => {
|
||||
statement.run([
|
||||
student.id,
|
||||
classItem.id,
|
||||
student.name,
|
||||
student.photo ? JSON.stringify(student.photo) : null,
|
||||
student.photoFileName ?? null,
|
||||
student.idPhoto ? JSON.stringify(student.idPhoto) : null,
|
||||
student.idPhotoFileName ?? null,
|
||||
student.idPhotoCrop ? JSON.stringify(student.idPhotoCrop) : null,
|
||||
index
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
runPrepared(
|
||||
database,
|
||||
'INSERT INTO generated_certificates (id, class_id, student_id, template_id, class_name, student_name, asset_file_name, jpg_url, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
(statement) => {
|
||||
for (const certificate of normalizedState.certificates) {
|
||||
statement.run([
|
||||
certificate.id,
|
||||
certificate.classId,
|
||||
certificate.studentId,
|
||||
certificate.template.id,
|
||||
certificate.className,
|
||||
certificate.studentName,
|
||||
certificate.asset.fileName,
|
||||
certificate.jpgUrl ?? null,
|
||||
certificate.updatedAt
|
||||
])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
runPrepared(
|
||||
database,
|
||||
'INSERT OR REPLACE INTO app_state (id, json, updated_at) VALUES (1, ?, ?)',
|
||||
(statement) => {
|
||||
statement.run([JSON.stringify(activeState), updatedAt])
|
||||
}
|
||||
)
|
||||
|
||||
database.run('COMMIT')
|
||||
} catch (error) {
|
||||
database.run('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
|
||||
if (previousAppStateBytes > 1_000_000) {
|
||||
database.run('VACUUM')
|
||||
}
|
||||
|
||||
persist(database)
|
||||
}
|
||||
|
||||
function loadStructuredState(
|
||||
database: SqlJsDatabase,
|
||||
savedState: Partial<PersistedAppState> = EMPTY_STATE
|
||||
): PersistedAppState {
|
||||
const templates = rows<{
|
||||
id: string
|
||||
name: string
|
||||
image_json: string
|
||||
config_json: string
|
||||
updated_at: string
|
||||
}>(database, 'SELECT id, name, image_json, config_json, updated_at FROM templates ORDER BY updated_at ASC').map(
|
||||
(template) => ({
|
||||
id: asString(template.id),
|
||||
name: asString(template.name),
|
||||
image: json(asString(template.image_json), EMPTY_STATE.templates[0]?.image),
|
||||
config: json(asString(template.config_json), EMPTY_STATE.templates[0]?.config),
|
||||
updatedAt: asString(template.updated_at)
|
||||
})
|
||||
)
|
||||
|
||||
const studentsByClass = new Map<string, PersistedAppState['classes'][number]['students']>()
|
||||
rows<{
|
||||
id: string
|
||||
class_id: string
|
||||
name: string
|
||||
photo_json: string | null
|
||||
photo_file_name: string | null
|
||||
id_photo_json: string | null
|
||||
id_photo_file_name: string | null
|
||||
id_photo_crop_json: string | null
|
||||
sort_order: number
|
||||
}>(
|
||||
database,
|
||||
'SELECT id, class_id, name, photo_json, photo_file_name, id_photo_json, id_photo_file_name, id_photo_crop_json, sort_order FROM students ORDER BY class_id ASC, sort_order ASC'
|
||||
).forEach((student) => {
|
||||
const classId = asString(student.class_id)
|
||||
const classStudents = studentsByClass.get(classId) ?? []
|
||||
const photoJson = asString(student.photo_json)
|
||||
const idPhotoJson = asString(student.id_photo_json)
|
||||
const idPhotoCropJson = asString(student.id_photo_crop_json)
|
||||
|
||||
classStudents.push({
|
||||
id: asString(student.id),
|
||||
name: asString(student.name),
|
||||
photo: photoJson ? json(photoJson, undefined) : undefined,
|
||||
photoFileName: asString(student.photo_file_name) || undefined,
|
||||
idPhoto: idPhotoJson ? json(idPhotoJson, undefined) : undefined,
|
||||
idPhotoFileName: asString(student.id_photo_file_name) || undefined,
|
||||
idPhotoCrop: idPhotoCropJson ? json(idPhotoCropJson, undefined) : undefined
|
||||
})
|
||||
studentsByClass.set(classId, classStudents)
|
||||
})
|
||||
|
||||
const classes = rows<{
|
||||
id: string
|
||||
name: string
|
||||
id_photo_config_json: string | null
|
||||
created_at: string
|
||||
}>(database, 'SELECT id, name, id_photo_config_json, created_at FROM classes ORDER BY created_at ASC').map((classItem) => ({
|
||||
id: asString(classItem.id),
|
||||
name: asString(classItem.name),
|
||||
students: studentsByClass.get(asString(classItem.id)) ?? [],
|
||||
idPhotoConfig: json(asString(classItem.id_photo_config_json), undefined),
|
||||
createdAt: asString(classItem.created_at)
|
||||
}))
|
||||
|
||||
const templatesById = new Map(templates.map((template) => [template.id, template]))
|
||||
const classesById = new Map(classes.map((classItem) => [classItem.id, classItem]))
|
||||
const studentsById = new Map(classes.flatMap((classItem) => classItem.students.map((student) => [student.id, student])))
|
||||
|
||||
const certificates = rows<{
|
||||
id: string
|
||||
class_id: string
|
||||
student_id: string
|
||||
template_id: string | null
|
||||
class_name: string
|
||||
student_name: string
|
||||
asset_file_name: string
|
||||
jpg_url: string | null
|
||||
updated_at: string
|
||||
}>(
|
||||
database,
|
||||
'SELECT id, class_id, student_id, template_id, class_name, student_name, asset_file_name, jpg_url, updated_at FROM generated_certificates ORDER BY updated_at ASC'
|
||||
).flatMap((certificate) => {
|
||||
const classId = asString(certificate.class_id)
|
||||
const studentId = asString(certificate.student_id)
|
||||
const template = templatesById.get(asString(certificate.template_id)) ?? templates[0]
|
||||
const student = studentsById.get(studentId)
|
||||
const photo = student?.photo
|
||||
|
||||
if (!template || !photo) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: asString(certificate.id),
|
||||
classId,
|
||||
className: asString(certificate.class_name) || classesById.get(classId)?.name || '',
|
||||
studentId,
|
||||
studentName: asString(certificate.student_name) || student?.name || '',
|
||||
photo,
|
||||
template,
|
||||
asset: {
|
||||
fileName: asString(certificate.asset_file_name)
|
||||
},
|
||||
jpgUrl: asString(certificate.jpg_url) || undefined,
|
||||
updatedAt: asString(certificate.updated_at)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
templates,
|
||||
activeTemplateId: savedState.activeTemplateId ?? templates[0]?.id ?? '',
|
||||
classes,
|
||||
activeClassId: savedState.activeClassId ?? classes[0]?.id ?? '',
|
||||
certificates
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user