Major refactor: deduplicate routes, sync, JS, and fix bugs

- Extract 8 common route patterns into factory functions in routes/shared.py
  (favourite, upload, replace cover, save defaults, clone, save JSON,
  get missing, clear covers) — removes ~1,100 lines across 9 route files
- Extract generic _sync_category() in sync.py — 7 sync functions become
  one-liner wrappers, removing ~350 lines
- Extract shared detail page JS into static/js/detail-common.js — all 9
  detail templates now call initDetailPage() with minimal config
- Extract layout inline JS into static/js/layout-utils.js (~185 lines)
- Extract library toolbar JS into static/js/library-toolbar.js
- Fix finalize missing-image bug: raise RuntimeError instead of logging
  warning so job is marked failed
- Fix missing scheduler default in _default_checkpoint_data()
- Fix N+1 query in Character.get_available_outfits() with batch IN query
- Convert all print() to logger across services and routes
- Add missing tags display to styles, scenes, detailers, checkpoints detail
- Update delete buttons to use trash.png icon with solid red background
- Update CLAUDE.md to reflect new architecture

Net reduction: ~1,600 lines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-21 23:06:58 +00:00
parent ed9a7b4b11
commit 55ff58aba6
42 changed files with 1493 additions and 3105 deletions

267
static/js/detail-common.js Normal file
View File

@@ -0,0 +1,267 @@
/**
* Shared JS for all resource detail pages.
*
* Usage: call initDetailPage(options) from the template's <script> block.
*
* Options:
* batchItems — Array of {slug, name} for batch generation (optional)
* jsonEditorUrl — URL for save_json route (optional, enables JSON editor)
* hasPreviewGallery — If true, enables addToPreviewGallery helper (default: !!batchItems)
*/
function initDetailPage(options = {}) {
const {
batchItems = [],
jsonEditorUrl = null,
hasPreviewGallery = batchItems.length > 0,
} = options;
// -----------------------------------------------------------------------
// DOM references
// -----------------------------------------------------------------------
const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
// -----------------------------------------------------------------------
// Favourite toggle (delegated)
// -----------------------------------------------------------------------
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.success) {
btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
}
});
});
// -----------------------------------------------------------------------
// Preview selection
// -----------------------------------------------------------------------
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
// Click any image with data-preview-path to select it
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
// -----------------------------------------------------------------------
// Job polling
// -----------------------------------------------------------------------
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') {
clearInterval(poll);
resolve(data);
} else if (data.status === 'failed' || data.status === 'removed') {
clearInterval(poll);
reject(new Error(data.error || 'Job failed'));
} else if (data.status === 'processing') {
progressLabel.textContent = 'Generating\u2026';
} else {
progressLabel.textContent = 'Queued\u2026';
}
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
}
// -----------------------------------------------------------------------
// Preview gallery helper
// -----------------------------------------------------------------------
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
if (!gallery) return;
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
const col = document.createElement('div');
col.className = 'col';
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
else {
const tab = document.getElementById('previews-tab');
if (tab) tab.insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
}
}
// -----------------------------------------------------------------------
// Form submit handler (single generation)
// -----------------------------------------------------------------------
if (form) {
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing\u2026';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued\u2026';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
if (hasPreviewGallery) {
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
}
if (typeof updateSeedFromResult === 'function') {
updateSeedFromResult(jobResult.result);
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
});
}
// -----------------------------------------------------------------------
// Endless mode callback
// -----------------------------------------------------------------------
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
if (hasPreviewGallery) {
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
}
};
// -----------------------------------------------------------------------
// Batch generation (secondary categories only)
// -----------------------------------------------------------------------
if (batchItems.length > 0) {
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
if (generateAllBtn) {
generateAllBtn.addEventListener('click', async () => {
if (batchItems.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
// Phase 1: submit all jobs
batchLabel.textContent = 'Queuing all characters\u2026';
const pending = [];
for (const char of batchItems) {
if (stopBatch) break;
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(
cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
const resp = await fetch(formAction, {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
// Phase 2: poll all in parallel
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) {
addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}
}).catch(err => {
done++;
console.error(`Failed for ${char.name}:`, err);
})
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => {
batchProgress.classList.add('d-none');
batchBar.style.width = '0%';
}, 3000);
});
}
if (stopAllBtn) {
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping\u2026';
});
}
}
// -----------------------------------------------------------------------
// JSON editor
// -----------------------------------------------------------------------
if (jsonEditorUrl && typeof initJsonEditor === 'function') {
initJsonEditor(jsonEditorUrl);
}
}

View File

@@ -307,11 +307,24 @@
applyGrid() {
const container = state.dom.container;
if (!container) return;
// Reset any custom positioning
// Reset container styles set by other layouts
container.style.height = '';
container.style.position = '';
container.style.gap = '';
// Reset any custom positioning on cards and their children
state.images.forEach(img => {
if (img.element) {
img.element.style.cssText = '';
// Reset child element inline styles set by mosaic/other layouts
const imgEl = img.element.querySelector('img');
if (imgEl) imgEl.style.cssText = '';
const badge = img.element.querySelector('.cat-badge');
if (badge) badge.style.cssText = '';
const overlay = img.element.querySelector('.overlay');
if (overlay) overlay.style.cssText = '';
}
});
},

204
static/js/layout-utils.js Normal file
View File

@@ -0,0 +1,204 @@
/**
* Shared utility functions used across the app layout.
* Previously defined inline in layout.html.
*/
/**
* Confirm and execute a resource delete (soft or hard).
* Called from the delete confirmation modal.
*/
async function confirmResourceDelete(mode) {
const resourceDeleteModal = bootstrap.Modal.getInstance(
document.getElementById('resourceDeleteModal'));
resourceDeleteModal.hide();
try {
const res = await fetch(`/resource/${_rdmCategory}/${_rdmSlug}/delete`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mode}),
});
const data = await res.json();
if (data.status === 'ok') {
const card = document.getElementById(`card-${_rdmSlug}`);
if (card) card.remove();
} else {
alert('Delete failed: ' + (data.error || 'unknown error'));
}
} catch (e) {
alert('Delete failed: ' + e);
}
}
/**
* Regenerate tags for a single resource via the LLM queue.
* Called from detail page tag regeneration buttons.
*/
function regenerateTags(category, slug) {
const btn = document.getElementById('regenerate-tags-btn');
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Regenerating\u2026';
fetch(`/api/${category}/${slug}/regenerate_tags`, {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(r => r.json().then(d => ({ok: r.ok, data: d})))
.then(({ok, data}) => {
if (ok && data.success) {
location.reload();
} else {
alert('Regeneration failed: ' + (data.error || 'Unknown error'));
btn.disabled = false;
btn.innerHTML = origText;
}
})
.catch(err => {
alert('Regeneration failed: ' + err);
btn.disabled = false;
btn.innerHTML = origText;
});
}
/**
* Initialize the JSON editor modal for a resource detail page.
* Provides simple form view and raw JSON (advanced) editing.
*/
function initJsonEditor(saveUrl) {
const jsonModal = document.getElementById('jsonEditorModal');
if (!jsonModal) return;
const textarea = document.getElementById('json-editor-textarea');
const errBox = document.getElementById('json-editor-error');
const simplePanel = document.getElementById('json-simple-panel');
const advancedPanel = document.getElementById('json-advanced-panel');
const simpleTab = document.getElementById('json-simple-tab');
const advancedTab = document.getElementById('json-advanced-tab');
let activeTab = 'simple';
function buildSimpleForm(data) {
simplePanel.innerHTML = '';
for (const [key, value] of Object.entries(data)) {
const row = document.createElement('div');
row.className = 'row mb-2 align-items-start';
const labelCol = document.createElement('div');
labelCol.className = 'col-sm-3 col-form-label fw-semibold text-capitalize small pt-1';
labelCol.textContent = key.replace(/_/g, ' ');
const inputCol = document.createElement('div');
inputCol.className = 'col-sm-9';
let el;
if (typeof value === 'boolean') {
const wrap = document.createElement('div');
wrap.className = 'form-check mt-2';
el = document.createElement('input');
el.type = 'checkbox';
el.className = 'form-check-input';
el.checked = value;
el.dataset.dtype = 'boolean';
wrap.appendChild(el);
inputCol.appendChild(wrap);
} else if (typeof value === 'number') {
el = document.createElement('input');
el.type = 'number';
el.step = 'any';
el.className = 'form-control form-control-sm';
el.value = value;
el.dataset.dtype = 'number';
inputCol.appendChild(el);
} else if (typeof value === 'string') {
if (value.length > 80) {
el = document.createElement('textarea');
el.className = 'form-control form-control-sm';
el.rows = 3;
} else {
el = document.createElement('input');
el.type = 'text';
el.className = 'form-control form-control-sm';
}
el.value = value;
el.dataset.dtype = 'string';
inputCol.appendChild(el);
} else {
el = document.createElement('textarea');
el.className = 'form-control form-control-sm font-monospace';
const lines = JSON.stringify(value, null, 2).split('\n');
el.rows = Math.min(10, lines.length + 1);
el.value = JSON.stringify(value, null, 2);
el.dataset.dtype = 'json';
inputCol.appendChild(el);
}
el.dataset.key = key;
row.appendChild(labelCol);
row.appendChild(inputCol);
simplePanel.appendChild(row);
}
}
function readSimpleForm() {
const result = {};
simplePanel.querySelectorAll('[data-key]').forEach(el => {
const key = el.dataset.key;
const dtype = el.dataset.dtype;
if (dtype === 'boolean') result[key] = el.checked;
else if (dtype === 'number') { const n = parseFloat(el.value); result[key] = isNaN(n) ? el.value : n; }
else if (dtype === 'json') { try { result[key] = JSON.parse(el.value); } catch { result[key] = el.value; } }
else result[key] = el.value;
});
return result;
}
simpleTab.addEventListener('click', () => {
errBox.classList.add('d-none');
let data;
try { data = JSON.parse(textarea.value); }
catch (e) { errBox.textContent = 'Cannot switch: invalid JSON \u2014 ' + e.message; errBox.classList.remove('d-none'); return; }
buildSimpleForm(data);
simplePanel.classList.remove('d-none');
advancedPanel.classList.add('d-none');
simpleTab.classList.add('active');
advancedTab.classList.remove('active');
activeTab = 'simple';
});
advancedTab.addEventListener('click', () => {
textarea.value = JSON.stringify(readSimpleForm(), null, 2);
advancedPanel.classList.remove('d-none');
simplePanel.classList.add('d-none');
advancedTab.classList.add('active');
simpleTab.classList.remove('active');
activeTab = 'advanced';
});
jsonModal.addEventListener('show.bs.modal', () => {
const raw = document.getElementById('json-raw-data').textContent;
let data;
try { data = JSON.parse(raw); } catch { data = {}; }
buildSimpleForm(data);
textarea.value = JSON.stringify(data, null, 2);
simplePanel.classList.remove('d-none');
advancedPanel.classList.add('d-none');
simpleTab.classList.add('active');
advancedTab.classList.remove('active');
activeTab = 'simple';
errBox.classList.add('d-none');
});
document.getElementById('json-save-btn').addEventListener('click', async () => {
errBox.classList.add('d-none');
let parsed;
if (activeTab === 'simple') {
parsed = readSimpleForm();
} else {
try { parsed = JSON.parse(textarea.value); }
catch (e) { errBox.textContent = 'Invalid JSON: ' + e.message; errBox.classList.remove('d-none'); return; }
}
const fd = new FormData();
fd.append('json_data', JSON.stringify(parsed));
const resp = await fetch(saveUrl, { method: 'POST', body: fd });
const result = await resp.json();
if (result.success) { bootstrap.Modal.getInstance(jsonModal).hide(); location.reload(); }
else { errBox.textContent = result.error || 'Save failed.'; errBox.classList.remove('d-none'); }
});
}

View File

@@ -0,0 +1,193 @@
/**
* Shared library toolbar JS — handles batch generation, tag regeneration,
* and bulk create operations for all category index pages.
*
* Reads configuration from data attributes on the toolbar wrapper element:
* data-toolbar-category — e.g. "outfits"
* data-get-missing-url — e.g. "/get_missing_outfits"
* data-clear-covers-url — e.g. "/clear_all_outfit_covers"
* data-generate-url — e.g. "/outfit/{slug}/generate" (with {slug} placeholder)
* data-regen-tags-category — e.g. "outfits" (for /admin/bulk_regenerate_tags/<cat>)
* data-bulk-create-url — e.g. "/outfits/bulk_create"
*/
document.addEventListener('DOMContentLoaded', () => {
const toolbar = document.querySelector('[data-toolbar-category]');
if (!toolbar) return;
const category = toolbar.dataset.toolbarCategory;
const getMissingUrl = toolbar.dataset.getMissingUrl;
const clearCoversUrl = toolbar.dataset.clearCoversUrl;
const generateUrlPattern = toolbar.dataset.generateUrl;
const regenTagsCat = toolbar.dataset.regenTagsCategory;
const bulkCreateUrl = toolbar.dataset.bulkCreateUrl;
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const regenTagsBtn = document.getElementById('regen-tags-all-btn');
const bulkCreateBtn = document.getElementById('bulk-create-btn');
const bulkOverwriteBtn = document.getElementById('bulk-overwrite-btn');
// --- Utility: poll a job until done ---
function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') {
clearInterval(poll); reject(new Error(data.error || 'Job failed'));
}
} catch (err) { /* ignore transient errors */ }
}, 1500);
});
}
// --- Batch Generate Missing Covers ---
async function runBatch() {
if (!getMissingUrl || !generateUrlPattern) return;
const response = await fetch(getMissingUrl);
const data = await response.json();
const missing = data.missing;
if (missing.length === 0) {
alert('No items missing cover images.');
return;
}
if (batchBtn) batchBtn.disabled = true;
if (regenAllBtn) regenAllBtn.disabled = true;
// Phase 1: Queue all jobs
const jobs = [];
for (const item of missing) {
try {
const url = generateUrlPattern.replace('{slug}', item.slug);
const body = new URLSearchParams({ action: 'replace' });
// Secondary categories need a random character for generation
if (category !== 'characters') {
body.set('character_slug', '__random__');
}
const genResp = await fetch(url, {
method: 'POST',
body: body,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
// Phase 2: Poll all concurrently, update card images as they finish
await Promise.all(jobs.map(async ({ item, jobId }) => {
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none');
}
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
}));
if (batchBtn) batchBtn.disabled = false;
if (regenAllBtn) regenAllBtn.disabled = false;
alert(`Batch generation complete! ${jobs.length} images queued.`);
}
if (batchBtn) {
batchBtn.addEventListener('click', async () => {
if (!getMissingUrl) return;
const response = await fetch(getMissingUrl);
const data = await response.json();
if (data.missing.length === 0) {
alert('No items missing cover images.');
return;
}
if (!confirm(`Generate cover images for ${data.missing.length} items?`)) return;
runBatch();
});
}
// --- Regenerate All Covers ---
if (regenAllBtn) {
regenAllBtn.addEventListener('click', async () => {
if (!clearCoversUrl) return;
if (!confirm('This will unassign ALL current cover images and generate new ones. Proceed?')) return;
const clearResp = await fetch(clearCoversUrl, { method: 'POST' });
if (clearResp.ok) {
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
runBatch();
}
});
}
// --- Regenerate Tags (LLM) ---
if (regenTagsBtn && regenTagsCat) {
regenTagsBtn.addEventListener('click', async () => {
if (!confirm('Regenerate tags for ALL items using the LLM? This will consume API credits.')) return;
regenTagsBtn.disabled = true;
const origText = regenTagsBtn.textContent;
regenTagsBtn.textContent = 'Queuing...';
try {
const resp = await fetch(`/admin/bulk_regenerate_tags/${regenTagsCat}`, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.success) {
alert(`Queued ${data.queued} tag regeneration tasks. Watch progress in the queue.`);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Request failed: ' + err.message);
}
regenTagsBtn.disabled = false;
regenTagsBtn.textContent = origText;
});
}
// --- Bulk Create from LoRAs (LLM) ---
async function doBulkCreate(overwrite) {
if (!bulkCreateUrl) return;
const body = overwrite ? new URLSearchParams({ overwrite: 'true' }) : undefined;
try {
const resp = await fetch(bulkCreateUrl, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: body,
});
const data = await resp.json();
if (data.success) {
alert(`Queued ${data.queued} LLM tasks (${data.skipped} skipped). Watch progress in the queue.`);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Request failed: ' + err.message);
}
}
if (bulkCreateBtn) {
bulkCreateBtn.addEventListener('click', () => {
if (!confirm('Create entries from LoRA files using the LLM? This will consume API credits.')) return;
doBulkCreate(false);
});
}
if (bulkOverwriteBtn) {
bulkOverwriteBtn.addEventListener('click', () => {
if (!confirm('WARNING: This will overwrite ALL existing metadata using the LLM. This consumes API credits. Proceed?')) return;
doBulkCreate(true);
});
}
});