const MODULE_ID = 'mgt2-compendium-amiral-denisov'; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; const IMAGE_EXTENSIONS = new Set(['png', 'webp', 'jpg', 'jpeg', 'gif', 'svg', 'avif', 'bmp', 'tiff', 'tif']); class BatchNpcDialog extends HandlebarsApplicationMixin(ApplicationV2) { static DEFAULT_OPTIONS = { id: 'mgt2-batch-npc', classes: ['mgt2-npc-dialog'], position: { width: 540, height: 'auto' }, window: { title: 'Création de PNJs par lots', resizable: true }, form: { submitOnClose: false, closeOnSubmit: false }, }; static PARTS = { main: { template: `modules/${MODULE_ID}/templates/batch-npc-dialog.hbs`, root: true, }, }; constructor(options = {}) { super(options); this._formData = { batch: { directory: '', folderName: 'PNJs importés', openSheets: false, }, }; } async _prepareContext() { return { ...this._formData }; } async _onRender(context, options) { await super._onRender(context, options); const html = $('#' + this.id).find('.window-content'); this._applyTheme(html); html.find('[data-action="browse-directory"]').on('click', async () => { const fp = new FilePicker({ type: 'image', current: this._formData.batch.directory || '', callback: (path) => { const dir = path.split('/').slice(0, -1).join('/'); this._formData.batch.directory = dir; html.find('[name="batch.directory"]').val(dir); }, }); fp.render(true); }); html.find('[data-action="batch-create"]').on('click', async () => { this._readForm(html); await this._executeBatch(html); }); } _readForm(html) { this._formData.batch.directory = (html.find('[name="batch.directory"]').val()?.trim() || '').replace(/\/+$/, ''); this._formData.batch.folderName = html.find('[name="batch.folderName"]').val()?.trim() || 'PNJs importés'; this._formData.batch.openSheets = html.find('[name="batch.openSheets"]').is(':checked'); } _applyTheme(html) { html.find('h3').css({ color: '#5f4300', 'border-bottom-color': '#b78f26', 'text-shadow': 'none' }); html.find('legend').css({ color: '#7a5c00', 'text-shadow': 'none' }); } async _executeBatch(html) { const { directory, folderName, openSheets } = this._formData.batch; if (!directory) { ui.notifications.warn('Veuillez spécifier un répertoire source.'); return; } const $progress = html.find('#batch-progress'); const $fill = html.find('#batch-progress-fill'); const $text = html.find('#batch-progress-text'); $progress.show(); try { $text.text('Analyse du répertoire...'); let files; try { const result = await FilePicker.browse('data', directory); files = result.files || []; } catch (e) { $progress.hide(); ui.notifications.error(`Impossible de lire le répertoire « ${directory} » : ${e.message}`); return; } const imageFiles = files.filter(f => { const ext = f.split('.').pop()?.toLowerCase(); return ext && IMAGE_EXTENSIONS.has(ext); }); if (imageFiles.length === 0) { $progress.hide(); ui.notifications.warn(`Aucune image trouvée dans « ${directory} ».`); return; } $text.text(`${imageFiles.length} image(s) trouvée(s). Vérification des doublons...`); const normalizePath = (p) => p.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '/').toLowerCase(); const existingImages = new Set(); for (const actor of game.actors) { if (actor.img && actor.img !== 'icons/svg/mystery-man.svg') { existingImages.add(normalizePath(actor.img)); } } const toCreate = imageFiles.filter(f => !existingImages.has(normalizePath(f))); if (toCreate.length === 0) { $progress.hide(); ui.notifications.info('Tous les PNJs existent déjà (aucune nouvelle image à traiter).'); return; } let folder = null; if (folderName) { folder = game.folders.find(f => f.name === folderName && f.type === 'Actor'); if (!folder) { folder = await Folder.create({ name: folderName, type: 'Actor', parent: null }); } } $text.text(`Création de ${toCreate.length} PNJ(s)...`); const chunkSize = 10; let created = 0; const allCreated = []; for (let i = 0; i < toCreate.length; i += chunkSize) { const chunk = toCreate.slice(i, i + chunkSize); const actorsData = chunk.map(path => { const name = path.split('/').pop().replace(/\.[^/.]+$/, ''); return { name, type: 'npc', img: path, folder: folder?.id || null, token: { name, img: path, actorLink: true, disposition: 0, sight: { enabled: false }, }, system: { settings: { hideUntrained: true, lockCharacteristics: true }, characteristics: {}, hits: {}, skills: {}, }, flags: { [MODULE_ID]: { batchImported: true, sourceImage: path }, }, }; }); const actors = await Actor.create(actorsData, { renderSheet: false }); allCreated.push(...actors); created += actors.length; const pct = Math.min(100, Math.round((i + chunkSize) / toCreate.length * 100)); $fill.css('width', pct + '%'); $text.text(`${created}/${toCreate.length} PNJ(s) créé(s)...`); } ui.actors?.render(false); $fill.css('width', '100%'); $text.text(`${created} PNJ(s) créé(s) avec succès dans le dossier « ${folderName} » !`); ui.notifications.info(`${created} PNJ(s) créé(s) à partir des images.`); if (openSheets && allCreated.length > 0) { const lastActor = allCreated[allCreated.length - 1]; if (lastActor) lastActor.sheet?.render(true); } } catch (error) { console.error(`${MODULE_ID} | Batch NPC creation error:`, error); $text.text(`Erreur : ${error.message}`); ui.notifications.error(`Erreur lors de la création : ${error.message}`); } } } Hooks.on('renderActorDirectory', (app, html, data) => { if (!game.user?.isGM) return; const $html = html instanceof jQuery ? html : $(html); if ($html.find('.mgt2-batch-npc-btn').length) return; const btn = $(``); const footer = $html.find('.directory-footer'); if (footer.length) { footer.append(btn); } else { $html.find('.directory-header').after(btn); } btn.on('click', () => { new BatchNpcDialog().render({ force: true }); }); });