diff --git a/conf/config.json b/conf/config.json index 3f8d054..c77a267 100644 --- a/conf/config.json +++ b/conf/config.json @@ -1 +1 @@ -{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":true,"sqlitepath":"/db"} \ No newline at end of file +{"sqlitepath":"/db"} \ No newline at end of file diff --git a/index.js b/index.js index 5211b08..b24062d 100755 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js'; +import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/utils.js'; import * as similarityCache from './lib/services/similarity-check/similarityCache.js'; import * as jobStorage from './lib/services/storage/jobStorage.js'; import FredyPipeline from './lib/FredyPipeline.js'; @@ -12,28 +12,34 @@ import { initTrackerCron } from './lib/services/crons/tracker-cron.js'; import logger from './lib/services/logger.js'; import { bus } from './lib/services/events/event-bus.js'; import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js'; +import { getSettings } from './lib/services/storage/settingsStorage.js'; +import SqliteConnection from './lib/services/storage/SqliteConnection.js'; + +//in the config, we store the path of the sqlite file, thus we must check if it is available +const isConfigAccessible = await checkIfConfigIsAccessible(); +await SqliteConnection.init(); // Load configuration before any other startup steps await refreshConfig(); -const isConfigAccessible = await checkIfConfigIsAccessible(); - if (!isConfigAccessible) { logger.error('Configuration exists, but is not accessible. Please check the file permission'); process.exit(1); } +// Run DB migrations once at startup and block until finished +await runMigrations(); + +const settings = await getSettings(); + // Ensure sqlite directory exists before loading anything else (based on config.sqlitepath) -const rawDir = config.sqlitepath || '/db'; +const rawDir = settings.sqlitepath || '/db'; const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir; const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir); if (!fs.existsSync(absDir)) { fs.mkdirSync(absDir, { recursive: true }); } -// Run DB migrations once at startup and block until finished -await runMigrations(); - // Load provider modules once at startup const providers = await getProviders(); @@ -41,17 +47,17 @@ similarityCache.initSimilarityCache(); similarityCache.startSimilarityCacheReloader(); //assuming interval is always in minutes -const INTERVAL = config.interval * 60 * 1000; +const INTERVAL = settings.interval * 60 * 1000; // Initialize API only after migrations completed await import('./lib/api/api.js'); -if (config.demoMode) { +if (settings.demoMode) { logger.info('Running in demo mode'); cleanupDemoAtMidnight(); } -logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`); +logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`); ensureAdminUserExists(); ensureDemoUserExists(); @@ -65,10 +71,10 @@ bus.on('jobs:runAll', () => { }); const execute = () => { - const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now()); - if (!config.demoMode) { + const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(settings, Date.now()); + if (!settings.demoMode) { if (isDuringWorkingHoursOrNotSet) { - config.lastRun = Date.now(); + settings.lastRun = Date.now(); jobStorage .getJobs() .filter((job) => job.enabled) diff --git a/lib/api/api.js b/lib/api/api.js index 348efd2..66c41b7 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -7,7 +7,6 @@ import { versionRouter } from './routes/versionRouter.js'; import { loginRouter } from './routes/loginRoute.js'; import { userRouter } from './routes/userRoute.js'; import { jobRouter } from './routes/jobRouter.js'; -import { config } from '../utils.js'; import bodyParser from 'body-parser'; import restana from 'restana'; import files from 'serve-static'; @@ -16,9 +15,10 @@ import { getDirName } from '../utils.js'; import { demoRouter } from './routes/demoRouter.js'; import logger from '../services/logger.js'; import { listingsRouter } from './routes/listingsRouter.js'; +import { getSettings } from '../services/storage/settingsStorage.js'; const service = restana(); const staticService = files(path.join(getDirName(), '../ui/public')); -const PORT = config.port || 9998; +const PORT = (await getSettings()).port || 9998; service.use(bodyParser.json()); service.use(cookieSession()); diff --git a/lib/api/routes/demoRouter.js b/lib/api/routes/demoRouter.js index 0bd5dfb..80e85b1 100644 --- a/lib/api/routes/demoRouter.js +++ b/lib/api/routes/demoRouter.js @@ -1,10 +1,11 @@ import restana from 'restana'; -import { config } from '../../utils.js'; +import { getSettings } from '../../services/storage/settingsStorage.js'; const service = restana(); const demoRouter = service.newRouter(); demoRouter.get('/', async (req, res) => { - res.body = Object.assign({}, { demoMode: config.demoMode }); + const settings = await getSettings(); + res.body = Object.assign({}, { demoMode: settings.demoMode }); res.send(); }); diff --git a/lib/api/routes/generalSettingsRoute.js b/lib/api/routes/generalSettingsRoute.js index 1462d64..ef41115 100644 --- a/lib/api/routes/generalSettingsRoute.js +++ b/lib/api/routes/generalSettingsRoute.js @@ -1,24 +1,30 @@ import restana from 'restana'; -import { config, getDirName, readConfigFromStorage, refreshConfig } from '../../utils.js'; +import { getDirName } from '../../utils.js'; import fs from 'fs'; import { ensureDemoUserExists } from '../../services/storage/userStorage.js'; import logger from '../../services/logger.js'; +import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js'; const service = restana(); const generalSettingsRouter = service.newRouter(); + generalSettingsRouter.get('/', async (req, res) => { - res.body = Object.assign({}, config); + res.body = Object.assign({}, await getSettings()); res.send(); }); generalSettingsRouter.post('/', async (req, res) => { - const settings = req.body; + const { sqlitepath, ...appSettings } = req.body || {}; + const localSettings = await getSettings(); + + if (localSettings.demoMode) { + res.send(new Error('In demo mode, it is not allowed to change these settings.')); + return; + } + try { - if (config.demoMode) { - res.send(new Error('In demo mode, it is not allowed to change these settings.')); - return; + if (typeof sqlitepath !== 'undefined') { + fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath })); } - const currentConfig = await readConfigFromStorage(); - fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...currentConfig, ...settings })); - await refreshConfig(); + upsertSettings(appSettings); ensureDemoUserExists(); } catch (err) { logger.error(err); diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js index 3b17c09..98e9bd7 100644 --- a/lib/api/routes/jobRouter.js +++ b/lib/api/routes/jobRouter.js @@ -1,10 +1,10 @@ import restana from 'restana'; import * as jobStorage from '../../services/storage/jobStorage.js'; import * as userStorage from '../../services/storage/userStorage.js'; -import { config } from '../../utils.js'; import { isAdmin } from '../security.js'; import logger from '../../services/logger.js'; import { bus } from '../../services/events/event-bus.js'; +import { getSettings } from '../../services/storage/settingsStorage.js'; const service = restana(); const jobRouter = service.newRouter(); @@ -44,9 +44,10 @@ jobRouter.get('/', async (req, res) => { }); jobRouter.get('/processingTimes', async (req, res) => { + const settings = await getSettings(); res.body = { - interval: config.interval, - lastRun: config.lastRun || null, + interval: settings.interval, + lastRun: settings.lastRun || null, }; res.send(); }); diff --git a/lib/api/routes/loginRoute.js b/lib/api/routes/loginRoute.js index 1eb28cc..16293cc 100644 --- a/lib/api/routes/loginRoute.js +++ b/lib/api/routes/loginRoute.js @@ -1,9 +1,9 @@ import restana from 'restana'; import * as userStorage from '../../services/storage/userStorage.js'; import * as hasher from '../../services/security/hash.js'; -import { config } from '../../utils.js'; import { trackDemoAccessed } from '../../services/tracking/Tracker.js'; import logger from '../../services/logger.js'; +import { getSettings } from '../../services/storage/settingsStorage.js'; const service = restana(); const loginRouter = service.newRouter(); loginRouter.get('/user', async (req, res) => { @@ -20,6 +20,7 @@ loginRouter.get('/user', async (req, res) => { res.send(); }); loginRouter.post('/', async (req, res) => { + const settings = await getSettings(); const { username, password } = req.body; const user = userStorage.getUsers(true).find((user) => user.username === username); if (user == null) { @@ -27,7 +28,7 @@ loginRouter.post('/', async (req, res) => { return; } if (user.password === hasher.hash(password)) { - if (config.demoMode) { + if (settings.demoMode) { await trackDemoAccessed(); } diff --git a/lib/api/routes/userRoute.js b/lib/api/routes/userRoute.js index ac8110e..c73d928 100644 --- a/lib/api/routes/userRoute.js +++ b/lib/api/routes/userRoute.js @@ -1,7 +1,7 @@ import restana from 'restana'; import * as userStorage from '../../services/storage/userStorage.js'; import * as jobStorage from '../../services/storage/jobStorage.js'; -import { config } from '../../utils.js'; +import { getSettings } from '../../services/storage/settingsStorage.js'; const service = restana(); const userRouter = service.newRouter(); function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) { @@ -23,7 +23,8 @@ userRouter.get('/:userId', async (req, res) => { res.send(); }); userRouter.delete('/', async (req, res) => { - if (config.demoMode) { + const settings = await getSettings(); + if (settings.demoMode) { res.send(new Error('In demo mode, it is not allowed to remove user.')); return; } @@ -44,7 +45,8 @@ userRouter.delete('/', async (req, res) => { res.send(); }); userRouter.post('/', async (req, res) => { - if (config.demoMode) { + const settings = await getSettings(); + if (settings.demoMode) { res.send(new Error('In demo mode, it is not allowed to change or add user.')); return; } diff --git a/lib/services/crons/demoCleanup-cron.js b/lib/services/crons/demoCleanup-cron.js index 79b5752..46e7796 100644 --- a/lib/services/crons/demoCleanup-cron.js +++ b/lib/services/crons/demoCleanup-cron.js @@ -1,8 +1,8 @@ import { removeJobsByUserId } from '../storage/jobStorage.js'; -import { config } from '../../utils.js'; import { getUsers } from '../storage/userStorage.js'; import logger from '../logger.js'; import cron from 'node-cron'; +import { getSettings } from '../storage/settingsStorage.js'; /** * if we are running in demo environment, we have to cleanup the db files (specifically the jobs table) @@ -11,12 +11,13 @@ export function cleanupDemoAtMidnight() { cron.schedule('0 0 * * *', cleanup); } -function cleanup() { - if (config.demoMode) { +async function cleanup() { + const settings = await getSettings(); + if (settings.demoMode) { const demoUser = getUsers(false).find((user) => user.username === 'demo'); if (demoUser == null) { logger.error('Demo user not found, cannot remove Jobs'); - return; + return Promise.resolve(); } removeJobsByUserId(demoUser.id); } diff --git a/lib/services/crons/tracker-cron.js b/lib/services/crons/tracker-cron.js index 7cfe8cc..4200df7 100644 --- a/lib/services/crons/tracker-cron.js +++ b/lib/services/crons/tracker-cron.js @@ -1,10 +1,12 @@ import cron from 'node-cron'; -import { config, inDevMode } from '../../utils.js'; +import { inDevMode } from '../../utils.js'; import { trackMainEvent } from '../tracking/Tracker.js'; +import { getSettings } from '../storage/settingsStorage.js'; async function runTask() { + const settings = await getSettings(); //make sure to only send tracking events if the user gave us the green light and we are not in dev mode - if (config.analyticsEnabled && !inDevMode()) { + if (settings.analyticsEnabled && !inDevMode()) { await trackMainEvent(); } } diff --git a/lib/services/storage/SqliteConnection.js b/lib/services/storage/SqliteConnection.js index eb4305f..1f6c9cb 100644 --- a/lib/services/storage/SqliteConnection.js +++ b/lib/services/storage/SqliteConnection.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import Database from 'better-sqlite3'; import logger from '../../services/logger.js'; -import { config } from '../../utils.js'; +import { readConfigFromStorage } from '../../utils.js'; /** * SqliteConnection @@ -25,6 +25,15 @@ import { config } from '../../utils.js'; class SqliteConnection { static #db = null; + static #sqlLiteCfg = null; + + static async init() { + if (this.#sqlLiteCfg == null) { + readConfigFromStorage().then((c) => { + this.#sqlLiteCfg = c.sqlitepath; + }); + } + } /** * Returns a singleton instance of better-sqlite3 Database. * Respects env var SQLITE_DB_PATH and defaults to db/listings.db. @@ -32,9 +41,12 @@ class SqliteConnection { static getConnection() { if (this.#db) return this.#db; + if (this.#sqlLiteCfg == null) { + logger.warn('No sqlitepath configured. Using default db/listings.db'); + } + // Interpret config.sqlitepath as a directory relative to project root when it starts with '/' - const cfg = typeof config === 'object' && config ? config.sqlitepath : undefined; - const rawDir = cfg && cfg.length > 0 ? cfg : '/db'; + const rawDir = this.#sqlLiteCfg && this.#sqlLiteCfg.length > 0 ? this.#sqlLiteCfg : '/db'; const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir; const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir); const dbPath = path.join(absDir, 'listings.db'); diff --git a/lib/services/storage/migrations/sql/6.settings.js b/lib/services/storage/migrations/sql/6.settings.js new file mode 100644 index 0000000..e562530 --- /dev/null +++ b/lib/services/storage/migrations/sql/6.settings.js @@ -0,0 +1,73 @@ +// Migration: Adding a settings table to store important (config) settings instead of using config file +import fs from 'fs'; +import path from 'path'; +import { nanoid } from 'nanoid'; +import logger from '../../../logger.js'; + +export function up(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS settings + ( + id TEXT PRIMARY KEY, + create_date INTEGER NOT NULL, + user_id TEXT, + name TEXT NOT NULL, + value jsonb NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_name ON settings (name); + `); + + // Helper to insert one setting row + const insertSetting = (name, rawValue) => { + try { + const id = nanoid(); + const createDate = Date.now(); + const value = JSON.stringify(rawValue); + db.prepare( + `INSERT INTO settings (id, create_date, name, value) + VALUES (@id, @create_date, @name, @value)`, + ).run({ id, create_date: createDate, name, value }); + } catch { + // Ignore duplicate inserts if any (unique by name) + } + }; + + // Migrate currently existing config.json into settings + try { + const configPath = path.resolve(process.cwd(), 'conf', 'config.json'); + + // Defaults + const defaults = { + interval: '60', + port: 9998, + workingHours: { from: '', to: '' }, + demoMode: false, + analyticsEnabled: true, + }; + + let config = {}; + if (fs.existsSync(configPath)) { + const file = fs.readFileSync(configPath, 'utf8'); + try { + config = JSON.parse(file) || {}; + } catch (parseErr) { + // If parsing fails, still proceed with defaults + logger.error(parseErr); + config = {}; + } + } + + // Insert each known setting, using the value from config when present, otherwise default + insertSetting('interval', config.interval != null ? config.interval : defaults.interval); + insertSetting('port', config.port != null ? config.port : defaults.port); + insertSetting('workingHours', config.workingHours != null ? config.workingHours : defaults.workingHours); + insertSetting('demoMode', config.demoMode != null ? config.demoMode : defaults.demoMode); + insertSetting( + 'analyticsEnabled', + config.analyticsEnabled != null ? config.analyticsEnabled : defaults.analyticsEnabled, + ); + } catch (e) { + logger.error(e); + } +} diff --git a/lib/services/storage/settingsStorage.js b/lib/services/storage/settingsStorage.js new file mode 100644 index 0000000..be081e7 --- /dev/null +++ b/lib/services/storage/settingsStorage.js @@ -0,0 +1,87 @@ +import { nanoid } from 'nanoid'; +import SqliteConnection from './SqliteConnection.js'; +import { fromJson, readConfigFromStorage, toJson } from '../../utils.js'; + +// In-memory cache for compiled settings config +/** @type {Record|null} */ +let cachedSettingsConfig = null; + +/** + * Build a config object from DB rows of settings. + * - Unwraps stored shape { value: any } into raw values. + * - Add additional config values from file config. E.g. sqlite part cannot be stored in db for obvious reasons ;) + * @param {{name:string, value:string|null}[]} rows + * @param {{name:value}} configValues + * @returns {Record} + */ +function compileSettings(rows, configValues) { + const config = {}; + for (const r of rows) { + const parsed = fromJson(r.value, null); + // unwrap { value: any } if present + config[r.name] = parsed && typeof parsed === 'object' && 'value' in parsed ? parsed.value : parsed; + } + return { + ...config, + ...configValues, + }; +} + +/** + * Force reload the settings config cache from DB and return it. + * @returns {Record} + */ +export async function refreshSettingsCache() { + const rows = SqliteConnection.query(`SELECT name, value FROM settings`); + const configValues = await readConfigFromStorage(); + cachedSettingsConfig = compileSettings(rows, configValues); + return cachedSettingsConfig; +} + +/** + * Get the compiled settings config. Loads it once and caches the result. + * @returns {Record} + */ +export async function getSettings() { + if (cachedSettingsConfig == null) { + return refreshSettingsCache(); + } + return cachedSettingsConfig; +} + +/** + * Upsert settings rows. + * - Accepts an object map of name -> value, or an entry {name, value}. + * - id: random string (nanoid) when inserting + * - create_date: epoch ms when inserting + * - name: unique key + * - value: JSON string of the raw value (no wrapper) + * @param {Record|{name:string, value:any}|[string, any][]} settingsMapOrEntry + * @returns {void} + */ +// Upsert one or more settings by name. Accepts either a single pair or an object map. +// Preferred usage: upsertSettings({ settingName: any, another: any }) +export function upsertSettings(settingsMapOrEntry, userId = null) { + const entries = Array.isArray(settingsMapOrEntry) + ? settingsMapOrEntry + : typeof settingsMapOrEntry === 'object' && + settingsMapOrEntry != null && + 'name' in settingsMapOrEntry && + 'value' in settingsMapOrEntry + ? [[settingsMapOrEntry.name, settingsMapOrEntry.value]] + : Object.entries(settingsMapOrEntry || {}); + + for (const [name, rawValue] of entries) { + const id = nanoid(); + const create_date = Date.now(); + const json = toJson(rawValue); + SqliteConnection.execute( + `INSERT INTO settings (id, create_date, name, value, user_id) + VALUES (@id, @create_date, @name, @value, @userId) + ON CONFLICT(name) DO UPDATE SET value = excluded.value`, + { id, create_date, name, value: json, userId }, + ); + } + // keep cache in sync + refreshSettingsCache(); +} diff --git a/lib/services/storage/userStorage.js b/lib/services/storage/userStorage.js index 1c97720..626eae3 100644 --- a/lib/services/storage/userStorage.js +++ b/lib/services/storage/userStorage.js @@ -1,7 +1,7 @@ -import { config } from '../../utils.js'; import * as hasher from '../security/hash.js'; import { nanoid } from 'nanoid'; import SqliteConnection from './SqliteConnection.js'; +import { getSettings } from './settingsStorage.js'; /** * Get all users. @@ -129,8 +129,9 @@ export const removeUser = (userId) => { * Security: The demo user's password is set to a known value ('demo') and should only be enabled in demoMode. * @returns {void} */ -export const ensureDemoUserExists = () => { - if (!config.demoMode) { +export const ensureDemoUserExists = async () => { + const settings = await getSettings(); + if (!settings.demoMode) { // Remove demo user (and cascade delete their jobs/listings) SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`); return; diff --git a/lib/services/tracking/Tracker.js b/lib/services/tracking/Tracker.js index a3e2f29..bc4bb59 100644 --- a/lib/services/tracking/Tracker.js +++ b/lib/services/tracking/Tracker.js @@ -1,9 +1,10 @@ import { getJobs } from '../storage/jobStorage.js'; import { getUniqueId } from './uniqueId.js'; -import { config, getPackageVersion, inDevMode } from '../../utils.js'; +import { getPackageVersion, inDevMode } from '../../utils.js'; import os from 'os'; import fetch from 'node-fetch'; import logger from '../logger.js'; +import { getSettings } from '../storage/settingsStorage.js'; const deviceId = getUniqueId() || 'N/A'; const version = await getPackageVersion(); @@ -11,7 +12,8 @@ const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking'; export const trackMainEvent = async () => { try { - if (config.analyticsEnabled && !inDevMode()) { + const settings = await getSettings(); + if (settings.analyticsEnabled && !inDevMode()) { const activeProvider = new Set(); const activeAdapter = new Set(); @@ -44,7 +46,8 @@ export const trackMainEvent = async () => { * Note, this will only be used when Fredy runs in demo mode */ export async function trackDemoAccessed() { - if (config.analyticsEnabled && !inDevMode() && config.demoMode) { + const settings = await getSettings(); + if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) { try { await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, { method: 'POST', @@ -56,7 +59,8 @@ export async function trackDemoAccessed() { } } -function enrichTrackingObject(trackingObject) { +async function enrichTrackingObject(trackingObject) { + const settings = await getSettings(); const operatingSystem = os.platform(); const osVersion = os.release(); const arch = process.arch; @@ -65,7 +69,7 @@ function enrichTrackingObject(trackingObject) { return { ...trackingObject, - isDemo: config.demoMode, + isDemo: settings.demoMode, operatingSystem, osVersion, arch, diff --git a/lib/utils.js b/lib/utils.js index b2d8689..f6d1fca 100755 --- a/lib/utils.js +++ b/lib/utils.js @@ -215,10 +215,6 @@ export async function refreshConfig() { try { config = await readConfigFromStorage(); - //backwards compatibility... - config.analyticsEnabled ??= null; - config.demoMode ??= false; - // default sqlitepath when missing in older configs config.sqlitepath ??= '/db'; } catch (error) { config = { ...DEFAULT_CONFIG }; @@ -306,7 +302,6 @@ export { getDirName, sleep, randomBetween, - config, buildHash, getPackageVersion, toJson, diff --git a/package.json b/package.json index 37d163c..3d6feb4 100755 --- a/package.json +++ b/package.json @@ -56,8 +56,8 @@ "Firefox ESR" ], "dependencies": { - "@douyinfe/semi-icons": "^2.88.0", - "@douyinfe/semi-ui": "2.88.0", + "@douyinfe/semi-icons": "^2.88.1", + "@douyinfe/semi-ui": "2.88.1", "@sendgrid/mail": "8.1.6", "@visactor/react-vchart": "^2.0.8", "@visactor/vchart": "^2.0.8", diff --git a/yarn.lock b/yarn.lock index fdf7175..bc3660b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -997,34 +997,34 @@ dependencies: tslib "^2.0.0" -"@douyinfe/semi-animation-react@2.88.0": - version "2.88.0" - resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.88.0.tgz#34d951e46a263b14db563b4044b3144f787e44e5" - integrity sha512-K6WzTDnLn75I+XOB/9C/hA2Mwjqd+TQpYiEjxSC+l3Ep6MiLS/5VbkGOSt4jiRJJQs584xfw59ReUJ5LGuPQLQ== +"@douyinfe/semi-animation-react@2.88.1": + version "2.88.1" + resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.88.1.tgz#edf263f35bd77bdd48ef158ad0c2d31456e277d2" + integrity sha512-vQeJEXd0hWwYafovYz7mcC/HOuUnt1QnCE/+KZx0gsuQ9CuBGUkCMuMDtHkmJtj4S8tQM440CTD7dh3ZC7yyUw== dependencies: - "@douyinfe/semi-animation" "2.88.0" - "@douyinfe/semi-animation-styled" "2.88.0" + "@douyinfe/semi-animation" "2.88.1" + "@douyinfe/semi-animation-styled" "2.88.1" classnames "^2.2.6" -"@douyinfe/semi-animation-styled@2.88.0": - version "2.88.0" - resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.88.0.tgz#abc29d577fc910ee3707af0f581548608c388d27" - integrity sha512-iHqrD2HoWL9Vd40DAsSjZHONHU91ayelMlziFoBjvvmaiuvcQms2ead7hLFkDtvkDswT0Mfd8BqkVDJSxTwxnw== +"@douyinfe/semi-animation-styled@2.88.1": + version "2.88.1" + resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.88.1.tgz#8a8fbb5613becdea26bec307a0b71d3bf1083f31" + integrity sha512-97qugh5GQWDHtDJbSLez7EYuC0oXgkhIMzRivBoaJ1i7jrDLVt+7Cua/CXiRnSoYi32c0ySQuns5M90/1gQD4g== -"@douyinfe/semi-animation@2.88.0": - version "2.88.0" - resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.88.0.tgz#2c069476b24a55041837e976b0d045c2c0da0049" - integrity sha512-J7fjwnVJEYvS2ZbKvWTjRRXTWQPlmYwkeXasICom+KFuE2vrkCzeqTXXIJ25MuaWlM/OWBPqrkAZBIfmNNQXWg== +"@douyinfe/semi-animation@2.88.1": + version "2.88.1" + resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.88.1.tgz#6224da91742040de43da98ef7f546888ff54a8be" + integrity sha512-xi1NE+L26sf8722O+4FUA1ycw8+qAsqHj4FofAFQoUzj5k4nwZi9KEhdEfcXfiF4ML6Kx+4LsA6J9N2pajpdWw== dependencies: bezier-easing "^2.1.0" -"@douyinfe/semi-foundation@2.88.0": - version "2.88.0" - resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.88.0.tgz#8fa4d5373acb5bb9f1e9fe1ca97c553c0ae76bfc" - integrity sha512-WYT1blbg2873xAU9iCasMRnTUsE/9WP/9gE1Zd87vsnZYWwl3WP9imH0iSqeSXkFdJllNo/KBImBY7clOoVIYA== +"@douyinfe/semi-foundation@2.88.1": + version "2.88.1" + resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.88.1.tgz#2eff45a3b61027a74f65460f00af4d9105ad42c1" + integrity sha512-GHQOiwTvlep77QF6Kw18UIeqIjaEDqJdraqTzS4J3ePO/KK9FsnzhyN5ggfhhNoAXXx+NHNFaTDKXxOPQXqCVA== dependencies: - "@douyinfe/semi-animation" "2.88.0" - "@douyinfe/semi-json-viewer-core" "2.88.0" + "@douyinfe/semi-animation" "2.88.1" + "@douyinfe/semi-json-viewer-core" "2.88.1" "@mdx-js/mdx" "^3.0.1" async-validator "^3.5.0" classnames "^2.2.6" @@ -1038,44 +1038,44 @@ remark-gfm "^4.0.0" scroll-into-view-if-needed "^2.2.24" -"@douyinfe/semi-icons@2.88.0", "@douyinfe/semi-icons@^2.88.0": - version "2.88.0" - resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.88.0.tgz#8bc28881aba3fa5a190599e1ddf4c6fb1840dbaa" - integrity sha512-kZSni5KZFL6fxs+c2nF4e3biPNcnAxV9U27577kOlaqP7l2FqP9U+d4x2YQisgsoT+Z3brqfWEayastQk5fzig== +"@douyinfe/semi-icons@2.88.1", "@douyinfe/semi-icons@^2.88.1": + version "2.88.1" + resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.88.1.tgz#e169d1c17571a3eadf84ad014294af42895f2380" + integrity sha512-ictYoa+9/9I/A+ioIoubIOY6vY5j285Nj8fJNo39LPr6OEH/Y80yL3aeaQOoi9vTHLx/iV8yp5fgk5NUoOZYeg== dependencies: classnames "^2.2.6" -"@douyinfe/semi-illustrations@2.88.0": - version "2.88.0" - resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.88.0.tgz#7ba4dad1fe98c813386c3baf7fd9720974cab1b3" - integrity sha512-fQ+Q9g9KjE9a2nH59uNHEzUdSt40GDloPCB4n7J3Q9EUeOiWpOsXbC/3NCDZc2ElZVryMChT3g6vjvIzHAl9Hw== +"@douyinfe/semi-illustrations@2.88.1": + version "2.88.1" + resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.88.1.tgz#5e0545536bf3d16b851c7c37f8c4bde771407248" + integrity sha512-ynnTxM4oTOi0byp08J5V/JRnTVdx8ACexwroYUDtiqNb7esNf1fcDPfkKPzuj3g7gwVJUnw4bpjquQk79yNOsw== -"@douyinfe/semi-json-viewer-core@2.88.0": - version "2.88.0" - resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.88.0.tgz#53cd6e6aa2a7f4b517c4cd532b08e65af4d60da7" - integrity sha512-LLdLZ477eJBQKlCPIqPhpIcXL1GOy9mvjpwryqiAj/h6BXmwcvp1zJwJQP9Rq9inePawdYMSZozaB2X1FPjKOg== +"@douyinfe/semi-json-viewer-core@2.88.1": + version "2.88.1" + resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.88.1.tgz#9cd502530658ca1e7d2d8d4b64aac84fbc91286c" + integrity sha512-kG7vEd9qPvQ2q3vOe8wT5VBiIATZ+4WPUUpexeg/otkF4Da0II5f2AO4CfGbPB9wFzIgDttRrLHX6QcRBmF4mQ== dependencies: jsonc-parser "^3.3.1" -"@douyinfe/semi-theme-default@2.88.0": - version "2.88.0" - resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.88.0.tgz#caa8c24c3afd3c24689a74efacdd6e11199cc22c" - integrity sha512-Cykl39Tkw9cJYTBpDToyj0uyXBGS15QDZGR2zCskdG52+eaCyZAoCds4W3HOxlToUmuw0JgVES5VSalIy3M07A== +"@douyinfe/semi-theme-default@2.88.1": + version "2.88.1" + resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.88.1.tgz#9b72a4f2a77a58c84a3c14e465ca4008da43b0e0" + integrity sha512-JKpC23F0ZCHlyazB4J3+vx43/+++odrgzZIGKHprXBTjbvqagu5wFe8mpeaY9mD8Nrd3ZeQJ3wApgwHuQR1fwA== -"@douyinfe/semi-ui@2.88.0": - version "2.88.0" - resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.88.0.tgz#a220fcfcad593f9669acb44b74c3c1e10efcb262" - integrity sha512-MlfLjUpTqnfk3Sg6pQOA2JETvZaWFEQwLvEcbfwA5LijX/hu7hG1Zhj1AVnpXTXrOUiU+ENTOiLu4GggoW2EaA== +"@douyinfe/semi-ui@2.88.1": + version "2.88.1" + resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.88.1.tgz#68dd9b2d7421c0741fe0599932b15aa51a314b38" + integrity sha512-x7HsvBn8AVbpLQcNk6C4vhAORmSBGN9kzbljSGWzFUx2xqrUz10bu39eBrKLMVuR6+GX3Gw22edPfx62QAaYow== dependencies: "@dnd-kit/core" "^6.0.8" "@dnd-kit/sortable" "^7.0.2" "@dnd-kit/utilities" "^3.2.1" - "@douyinfe/semi-animation" "2.88.0" - "@douyinfe/semi-animation-react" "2.88.0" - "@douyinfe/semi-foundation" "2.88.0" - "@douyinfe/semi-icons" "2.88.0" - "@douyinfe/semi-illustrations" "2.88.0" - "@douyinfe/semi-theme-default" "2.88.0" + "@douyinfe/semi-animation" "2.88.1" + "@douyinfe/semi-animation-react" "2.88.1" + "@douyinfe/semi-foundation" "2.88.1" + "@douyinfe/semi-icons" "2.88.1" + "@douyinfe/semi-illustrations" "2.88.1" + "@douyinfe/semi-theme-default" "2.88.1" "@tiptap/core" "^3.1.0" "@tiptap/extension-document" "^3.3.0" "@tiptap/extension-hard-break" "^3.3.0"