Ctrl K
ComunidadGuías…

routes/pluginBuilderRoutes.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import express from 'express';
import Item from '../models/Item';
import Settings from '../models/Settings';
import { requireAuth, requireCollectionRole } from '../middleware/authMiddleware';
import { registry } from '../core/registry';
import {
  buildConfigFromSubmission,
  writeCustomPluginDir,
  deleteCustomPluginDir,
  getCustomConfig,
  isValidIcon,
  CUSTOM_PLUGIN_PALETTE,
  CUSTOM_PLUGIN_ICONS
} from '../core/customPluginStore';
import { sanitizeExtraFields, getExtraFields, nativeLookalikesByPlugin } from '../core/pluginExtraFields';
import { SUPPORTED_LANGUAGES } from '../config/constants';
import { placeholderUrl } from '../core/placeholderImage';
import { cardFieldCandidates, isTranslationKey, getCardLines, MAX_CARD_LINES, CORNER_POSITIONS, DEFAULT_CORNER_POSITION } from '../core/cardFields';
import { filterCandidates, filterOverrides, activeFilters, sanitizeFilterSelection } from '../core/filterFields';
import { registerPluginDirAtRuntime, unregisterPluginAtRuntime } from '../core/pluginRuntime';
import { saveCustomPluginToDB, deleteCustomPluginFromDB } from '../core/customPluginSync';
import { generatePluginDraft } from '../core/ai/pluginGenerate';
import { getAiConfig } from '../core/ai/instance';
import { isAiConfigured } from '../core/ai/config';

/**
 * /create-plugin: builder for user-created ("custom") plugins.
 *
 * Collection admins design a manual-only item type (fields, formats, image shape...)
 * with a live preview; saving writes plugins/<id>/{plugin.json,index.ts} and
 * hot-registers the plugin without restarting the server.
 */
const router = express.Router();

router.use(requireAuth, requireCollectionRole('admin'));

/** A description is a sentence, not a pasted list: generous but not import-sized. */
const MAX_DESCRIPTION_CHARS = 2000;

async function listCustomPlugins() {
  const out: { config: any; itemCount: number }[] = [];
  for (const p of registry.getAll()) {
    const config = getCustomConfig(p);
    if (!config) continue;
    // Instance-wide count: the plugin folder is global, not per-collection
    const itemCount = await Item.countDocuments({ kind: p.kind });
    out.push({ config, itemCount });
  }
  return out;
}

// GET /create-plugin[?edit=<id>] -> plugin editor page
router.get('/', async (req: any, res: any) => {
  try {
    const customPlugins = await listCustomPlugins();
    const editId = typeof req.query.edit === 'string' ? req.query.edit : '';
    const edited = editId
      ? (customPlugins.find(c => c.config.id === editId)?.config || null)
      : null;

    // An uploaded default cover is a few hundred kB of base64 and the config is inlined
    // in the page: the builder only gets a URL to preview it, and the stored value is
    // kept server-side unless the form actually posts a new one.
    const editPlaceholder = edited ? placeholderUrl(edited.id, edited.defaultCover) : '';
    let editConfig: any = null;
    if (edited) {
      const { defaultCover, ...rest } = edited;
      editConfig = rest;
    }

    const settings = res.locals.settings;
    const customization = settings?.pluginCustomization || {};

    // Cosmetic-override targets: every plugin, with translated labels for the modal.
    // Read through the decorated registry so the collection's user-defined fields are
    // offered as card fields too.
    // Read on the bare registry: the decorated one lists the user-defined fields among
    // the plugin's own, which would make every one of them look like itself.
    const lookalikes = nativeLookalikesByPlugin(
      settings,
      registry.getAll(),
      key => SUPPORTED_LANGUAGES.map(lng => req.t(key, { lng }))
    );

    const customizablePlugins = res.locals.registry.getAll().map((p: any) => ({
      id: p.id,
      label: req.t(p.label),
      icon: p.icon,
      isCustom: !!getCustomConfig(p),
      enabled: settings?.modules?.[p.collectionType] === true,
      formats: (p.formats || []).map((f: any) => ({ value: f.value, label: req.t(f.label) })),
      current: customization[p.id] || {},
      extraFields: getExtraFields(settings, p.id),
      // User-defined field key -> label of the plugin's own field it looks like
      lookalikes: Object.fromEntries((lookalikes[p.id] || []).map(l => [l.name, req.t(l.nativeLabel)])),
      cardFieldChoices: cardFieldCandidates(p).map(f => ({
        name: f.name,
        label: req.t(f.label, { defaultValue: f.label })
      })),
      // What the cards show today, so an untouched plugin opens on its real state
      cardFields: p.defaultCardFields || [p.creatorField],
      // Read on the bare plugin for the same reason as the lookalikes above
      filterChoices: (() => {
        const bare = registry.get(p.id);
        if (!bare) return [];
        const candidates = filterCandidates(bare, getExtraFields(settings, p.id));
        const on = new Set(activeFilters(candidates, filterOverrides(customization, p.id)).map(c => c.id));
        return candidates.map(c => ({
          id: c.id,
          label: isTranslationKey(c.label) ? req.t(c.label, { defaultValue: c.label }) : c.label,
          on: on.has(c.id)
        }));
      })()
    }));

    res.render('create-plugin', {
      user: res.locals.user,
      customPlugins,
      customizablePlugins,
      editConfig,
      editPlaceholder,
      palette: CUSTOM_PLUGIN_PALETTE,
      iconChoices: CUSTOM_PLUGIN_ICONS,
      maxCardLines: MAX_CARD_LINES,
      aiConfigured: isAiConfigured(await getAiConfig()),
      maxAiDescriptionChars: MAX_DESCRIPTION_CHARS
    });
  } catch (err: any) {
    console.error('[PluginBuilder] page error:', err);
    res.status(500).send(req.t('errors.generic_server_error'));
  }
});

// POST /create-plugin/customize/:pluginId -> per-collection cosmetic overrides
// (icon, format badge colors). An empty submission clears the override.
router.post('/customize/:pluginId', async (req: any, res: any) => {
  try {
    const plugin = registry.get(req.params.pluginId);
    if (!plugin) {
      return res.status(404).json({ success: false, error: req.t('errors.not_found') });
    }
    const activeCollectionId = res.locals.activeCollectionId;
    if (!activeCollectionId) {
      return res.status(400).json({ success: false, error: req.t('errors.generic_server_error') });
    }

    const cosmetics: any = {};
    const icon = typeof req.body.icon === 'string' ? req.body.icon.trim() : '';
    if (icon) {
      if (!isValidIcon(icon)) {
        return res.status(400).json({ success: false, error: req.t('create_plugin.err_bad_icon') });
      }
      cosmetics.icon = icon;
    }

    const submitted = req.body.formatColors || {};
    const formatColors: Record<string, string> = {};
    for (const f of plugin.formats || []) {
      const color = submitted[f.value];
      if (typeof color === 'string' && (CUSTOM_PLUGIN_PALETTE as readonly string[]).includes(color)) {
        formatColors[f.value] = color;
      }
    }
    if (Object.keys(formatColors).length > 0) cosmetics.formatColors = formatColors;

    if (req.body.sortFormats === true || req.body.sortFormats === 'true') cosmetics.sortFormats = true;

    // Corner field. Checked against what the plugin actually offers, so a crafted payload
    // cannot put an arbitrary path on every cover; an empty submission clears it.
    if (typeof req.body.cornerField === 'string' && req.body.cornerField) {
      const decorated = res.locals.registry.get(plugin.id) || plugin;
      if (cardFieldCandidates(decorated).some(f => f.name === req.body.cornerField)) {
        cosmetics.cornerField = req.body.cornerField;
        // Only ever one of the free corners; anything else falls back to the default
        // rather than being written and rendered as a broken class string.
        cosmetics.cornerPosition = Object.prototype.hasOwnProperty.call(CORNER_POSITIONS, req.body.cornerPosition)
          ? req.body.cornerPosition
          : DEFAULT_CORNER_POSITION;
      }
    }

    // Card fields. Reordered to the plugin's own field order (the modal is a selection,
    // not a ranking) and capped, so a crafted payload cannot overflow the card.
    if (Array.isArray(req.body.cardFields)) {
      const decorated = res.locals.registry.get(plugin.id) || plugin;
      const picked = new Set(req.body.cardFields.filter((n: any) => typeof n === 'string'));
      cosmetics.cardFields = cardFieldCandidates(decorated)
        .map(f => f.name)
        .filter(name => picked.has(name))
        .slice(0, MAX_CARD_LINES);
    }

    // Filters shown on the collection page once this type is selected. Only what differs
    // from the default is kept (see core/filterFields.ts), so leaving every box as it
    // came stores nothing. Read against the fields declared before this save: one added
    // in the same submission has no box yet and simply keeps its default.
    if (Array.isArray(req.body.filters)) {
      const candidates = filterCandidates(plugin, getExtraFields(res.locals.settings, plugin.id));
      const filters = sanitizeFilterSelection(candidates, req.body.filters);
      if (Object.keys(filters).length > 0) cosmetics.filters = filters;
    }

    // User-defined fields. Only validated and written when the submission carries the
    // key, so a caller that only changes the icon never touches the declared fields.
    let extraUpdate: { set?: any; unset?: string } | null = null;
    if (req.body.extraFields !== undefined) {
      const existingFields = getExtraFields(res.locals.settings, plugin.id);
      const { fields, errors } = sanitizeExtraFields(req.body.extraFields, plugin, existingFields);
      if (errors.length > 0) {
        return res.status(400).json({ success: false, error: errors.map(e => req.t(e)).join(' ') });
      }
      const path = `pluginExtraFields.${plugin.id}`;
      extraUpdate = fields.length > 0 ? { set: { [path]: fields } } : { unset: path };
    }

    const cosmeticsPath = `pluginCustomization.${plugin.id}`;
    const $set: any = {};
    const $unset: any = {};
    if (cosmetics.icon || cosmetics.formatColors || cosmetics.cardFields || cosmetics.sortFormats || cosmetics.cornerField || cosmetics.filters) $set[cosmeticsPath] = cosmetics;
    else $unset[cosmeticsPath] = '';
    if (extraUpdate?.set) Object.assign($set, extraUpdate.set);
    if (extraUpdate?.unset) $unset[extraUpdate.unset] = '';

    const update: any = {};
    if (Object.keys($set).length > 0) update.$set = $set;
    if (Object.keys($unset).length > 0) update.$unset = $unset;
    await Settings.updateOne({ collection: activeCollectionId }, update);

    res.json({ success: true });
  } catch (err: any) {
    console.error('[PluginBuilder] customize error:', err);
    res.status(500).json({ success: false, error: req.t('errors.generic_server_error') });
  }
});

// POST /create-plugin/ai-generate -> draft a plugin config from a text description
router.post('/ai-generate', async (req: any, res: any) => {
  try {
    const description = typeof req.body?.description === 'string' ? req.body.description.trim() : '';
    if (!description) {
      return res.status(400).json({ success: false, errors: [req.t('create_plugin.ai_err_empty')] });
    }
    if (description.length > MAX_DESCRIPTION_CHARS) {
      return res.status(400).json({
        success: false,
        errors: [req.t('create_plugin.ai_err_too_long', { max: MAX_DESCRIPTION_CHARS })]
      });
    }

    const config = await getAiConfig();
    if (!isAiConfigured(config)) {
      return res.status(400).json({ success: false, errors: [req.t('ai.err_not_configured')] });
    }

    let draft;
    try {
      draft = await generatePluginDraft(config, description);
    } catch (err: any) {
      return res.status(502).json({
        success: false,
        errors: [req.t('create_plugin.ai_err_generation_failed', { error: err.message })]
      });
    }
    if (!draft) {
      return res.status(502).json({ success: false, errors: [req.t('create_plugin.ai_err_failed')] });
    }

    res.json({ success: true, draft });
  } catch (err: any) {
    console.error('[PluginBuilder] AI generate error:', err);
    res.status(500).json({ success: false, errors: [req.t('errors.generic_server_error')] });
  }
});

// POST /create-plugin/save -> create or update a custom plugin (JSON API)
router.post('/save', async (req: any, res: any) => {
  try {
    const editId = typeof req.body.editId === 'string' ? req.body.editId : '';
    const existingPlugin = editId ? registry.get(editId) : undefined;
    const existing = existingPlugin ? getCustomConfig(existingPlugin) : undefined;
    if (editId && !existing) {
      return res.status(404).json({ success: false, errors: [req.t('errors.not_found')] });
    }

    const { config, errors } = buildConfigFromSubmission(req.body, existing);
    if (!config) {
      return res.status(400).json({ success: false, errors: errors.map((k: string) => req.t(k)) });
    }

    writeCustomPluginDir(config);
    const result = registerPluginDirAtRuntime(config.id, existing?.id);
    if (!result.plugin) {
      // Should not happen (config was validated), but never leave a broken folder behind
      try { deleteCustomPluginDir(config.id); } catch { /* best effort */ }
      console.error(`[PluginBuilder] hot-register failed for ${config.id}:`, result.errors);
      return res.status(500).json({ success: false, errors: result.errors });
    }

    // Rename: drop the old folder once the new registration is live
    if (existing && existing.id !== config.id) {
      try { deleteCustomPluginDir(existing.id); } catch (err: any) {
        console.warn(`[PluginBuilder] could not remove old folder plugins/${existing.id}: ${err.message}`);
      }
    }

    // Persist to the DB (source of truth). The folder is just a regenerable cache,
    // so this is what makes the plugin survive rebuilds and travel with backups.
    await saveCustomPluginToDB(config, existing?.id);

    // Enable the module right away for the admin's active collection
    const activeCollectionId = res.locals.activeCollectionId;
    if (activeCollectionId) {
      const update: any = { $set: { [`modules.${config.id}`]: true } };
      if (existing && existing.id !== config.id) {
        update.$unset = { [`modules.${existing.id}`]: '' };
      }
      await Settings.updateOne({ collection: activeCollectionId }, update);
    }

    res.json({ success: true, id: config.id });
  } catch (err: any) {
    console.error('[PluginBuilder] save error:', err);
    res.status(500).json({ success: false, errors: [req.t('errors.generic_server_error')] });
  }
});

// POST /create-plugin/delete/:id -> remove a custom plugin (items stay in DB)
router.post('/delete/:id', async (req: any, res: any) => {
  try {
    const plugin = registry.get(req.params.id);
    const config = plugin ? getCustomConfig(plugin) : undefined;
    if (!plugin || !config) {
      return res.status(404).json({ success: false, error: req.t('errors.not_found') });
    }

    deleteCustomPluginDir(config.id);
    unregisterPluginAtRuntime(config.id);
    await deleteCustomPluginFromDB(config.id);

    res.json({ success: true });
  } catch (err: any) {
    console.error('[PluginBuilder] delete error:', err);
    res.status(500).json({ success: false, error: req.t('errors.generic_server_error') });
  }
});

export default router;