storing settings in db

This commit is contained in:
orangecoding
2025-11-17 12:06:26 +01:00
parent 656a615b4a
commit 3d40d9a548
18 changed files with 297 additions and 105 deletions

View File

@@ -1 +1 @@
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":true,"sqlitepath":"/db"}
{"sqlitepath":"/db"}

View File

@@ -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)

View File

@@ -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());

View File

@@ -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();
});

View File

@@ -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);

View File

@@ -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();
});

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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');

View File

@@ -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);
}
}

View File

@@ -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<string, any>|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<string, any>}
*/
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<string, any>}
*/
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<string, any>}
*/
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<string, any>|{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();
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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"