diff --git a/public/js/spinner.js b/public/js/spinner.js
new file mode 100644
index 00000000..de517bbb
--- /dev/null
+++ b/public/js/spinner.js
@@ -0,0 +1,229 @@
+/**
+ * Njobvu-AI Reusable Loading Spinner Helper
+ * Provides inline button spinners, form loading states, overlay spinners, and fetch wrappers.
+ */
+(function(window) {
+ 'use strict';
+
+ // Inject CSS styles if not present
+ function injectStyles() {
+ if (typeof document === 'undefined') return;
+ if (document.getElementById('njobvu-spinner-styles')) return;
+ const style = document.createElement('style');
+ style.id = 'njobvu-spinner-styles';
+ style.textContent = `
+ .njobvu-spinner-inline {
+ display: inline-block;
+ width: 1em;
+ height: 1em;
+ vertical-align: -0.125em;
+ border: 0.15em solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: njobvu-spinner-spin .75s linear infinite;
+ margin-right: 0.5rem;
+ }
+ .njobvu-spinner-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ z-index: 9999;
+ color: #ffffff;
+ font-family: inherit;
+ }
+ .njobvu-spinner-card {
+ background: #1e293b;
+ color: #f8fafc;
+ padding: 1.5rem 2rem;
+ border-radius: 8px;
+ box-shadow: 0 10px 25px rgba(0,0,0,0.3);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ max-width: 90%;
+ }
+ .njobvu-spinner-lg {
+ width: 2.5rem;
+ height: 2.5rem;
+ border-width: 0.25em;
+ margin-right: 0;
+ margin-bottom: 1rem;
+ }
+ @keyframes njobvu-spinner-spin {
+ 100% { transform: rotate(360deg); }
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ if (typeof document !== 'undefined') {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', injectStyles);
+ } else {
+ injectStyles();
+ }
+ }
+
+ const LoadingSpinner = {
+ /**
+ * Shows spinner on a target element (button, submit input, or container) or full screen overlay if target is null.
+ * @param {Element|string} [target] - Element or selector to attach spinner to
+ * @param {Object|string} [options] - Options or text message
+ */
+ show: function(target, options) {
+ injectStyles();
+ let text = typeof options === 'string' ? options : (options && options.text !== undefined ? options.text : 'Processing...');
+
+ if (!target) {
+ return this.showOverlay(text);
+ }
+
+ const el = typeof target === 'string' && typeof document !== 'undefined' ? document.querySelector(target) : target;
+ if (!el) {
+ return this.showOverlay(text);
+ }
+
+ // If target is a form, target its submit button(s)
+ if (el.tagName === 'FORM') {
+ const submitBtns = el.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type])');
+ submitBtns.forEach(btn => this.show(btn, options));
+ return;
+ }
+
+ // Save original state if not already saved
+ if (!el.dataset.njobvuOriginalHtml) {
+ el.dataset.njobvuOriginalHtml = el.innerHTML || el.value || '';
+ }
+ if (el.dataset.njobvuOriginalDisabled === undefined) {
+ el.dataset.njobvuOriginalDisabled = el.disabled ? 'true' : 'false';
+ }
+
+ el.disabled = true;
+ el.classList.add('njobvu-btn-loading');
+
+ const spinnerHtml = ``;
+
+ if (el.tagName === 'INPUT' && (el.type === 'submit' || el.type === 'button')) {
+ el.value = text ? text : 'Processing...';
+ } else {
+ el.innerHTML = spinnerHtml + (text ? `${text}` : el.dataset.njobvuOriginalHtml);
+ }
+ },
+
+ /**
+ * Restores target element to its original state
+ * @param {Element|string} [target]
+ */
+ hide: function(target) {
+ if (!target) {
+ this.hideOverlay();
+ return;
+ }
+
+ const el = typeof target === 'string' && typeof document !== 'undefined' ? document.querySelector(target) : target;
+ if (!el) {
+ this.hideOverlay();
+ return;
+ }
+
+ if (el.tagName === 'FORM') {
+ const submitBtns = el.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type])');
+ submitBtns.forEach(btn => this.hide(btn));
+ return;
+ }
+
+ if (el.dataset && el.dataset.njobvuOriginalHtml !== undefined) {
+ if (el.tagName === 'INPUT' && (el.type === 'submit' || el.type === 'button')) {
+ el.value = el.dataset.njobvuOriginalHtml;
+ } else {
+ el.innerHTML = el.dataset.njobvuOriginalHtml;
+ }
+ delete el.dataset.njobvuOriginalHtml;
+ }
+
+ if (el.dataset && el.dataset.njobvuOriginalDisabled !== undefined) {
+ el.disabled = el.dataset.njobvuOriginalDisabled === 'true';
+ delete el.dataset.njobvuOriginalDisabled;
+ } else {
+ el.disabled = false;
+ }
+
+ el.classList.remove('njobvu-btn-loading');
+ },
+
+ /**
+ * Shows full-screen overlay spinner with message
+ */
+ showOverlay: function(message) {
+ if (typeof document === 'undefined') return;
+ injectStyles();
+ let overlay = document.getElementById('njobvu-overlay-spinner');
+ if (!overlay) {
+ overlay = document.createElement('div');
+ overlay.id = 'njobvu-overlay-spinner';
+ overlay.className = 'njobvu-spinner-overlay';
+ overlay.innerHTML = `
+
+
+
${message || 'Loading, please wait...'}
+
+ `;
+ document.body.appendChild(overlay);
+ } else {
+ const textEl = document.getElementById('njobvu-overlay-text');
+ if (textEl) textEl.textContent = message || 'Loading, please wait...';
+ overlay.style.display = 'flex';
+ }
+ },
+
+ /**
+ * Updates text of existing overlay spinner
+ */
+ updateOverlayText: function(message) {
+ if (typeof document === 'undefined') return;
+ const textEl = document.getElementById('njobvu-overlay-text');
+ if (textEl) textEl.textContent = message;
+ },
+
+ /**
+ * Hides full-screen overlay spinner
+ */
+ hideOverlay: function() {
+ if (typeof document === 'undefined') return;
+ const overlay = document.getElementById('njobvu-overlay-spinner');
+ if (overlay) {
+ overlay.style.display = 'none';
+ }
+ },
+
+ /**
+ * Wraps a fetch promise or async function call with show/hide spinner handlers
+ */
+ wrapFetch: async function(fetchPromiseOrFn, target, options) {
+ this.show(target, options);
+ try {
+ const promise = typeof fetchPromiseOrFn === 'function' ? fetchPromiseOrFn() : fetchPromiseOrFn;
+ const result = await promise;
+ return result;
+ } finally {
+ this.hide(target);
+ }
+ }
+ };
+
+ if (typeof window !== 'undefined') {
+ window.LoadingSpinner = LoadingSpinner;
+ window.showSpinner = function(target, options) { LoadingSpinner.show(target, options); };
+ window.hideSpinner = function(target) { LoadingSpinner.hide(target); };
+ }
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = LoadingSpinner;
+ }
+})(typeof window !== 'undefined' ? window : this);
diff --git a/queries/projects/projects.js b/queries/projects/projects.js
index 1c5851ef..ef7bcf11 100644
--- a/queries/projects/projects.js
+++ b/queries/projects/projects.js
@@ -204,6 +204,20 @@ module.exports = {
await db.run(
"CREATE TABLE IF NOT EXISTS Images (IName VARCHAR NOT NULL PRIMARY KEY, reviewImage INTEGER NOT NULL DEFAULT 0, validateImage INTEGER NOT NULL DEFAULT 0)",
);
+ try {
+ await db.run(
+ "ALTER TABLE Images ADD COLUMN reviewImage INTEGER NOT NULL DEFAULT 0",
+ );
+ } catch (e) {
+ // Column already exists
+ }
+ try {
+ await db.run(
+ "ALTER TABLE Images ADD COLUMN validateImage INTEGER NOT NULL DEFAULT 0",
+ );
+ } catch (e) {
+ // Column already exists
+ }
await db.run(
"CREATE TABLE IF NOT EXISTS Labels (LID INTEGER PRIMARY KEY, CName VARCHAR NOT NULL, X VARCHAR NOT NULL, Y VARCHAR NOT NULL, W INTEGER NOT NULL, H INTEGER NOT NULL, IName VARCHAR NOT NULL, FOREIGN KEY(CName) REFERENCES Classes(CName), FOREIGN KEY(IName) REFERENCES Images(IName))",
);
diff --git a/routes/api.js b/routes/api.js
index 111e6915..72f9bd42 100755
--- a/routes/api.js
+++ b/routes/api.js
@@ -59,6 +59,7 @@ const importDataset = require("./projects/importDataset");
const importYolo = require("./projects/importYolo");
const importKwCoco = require("./projects/importKwCoco");
const importIfcb = require("./projects/importIfcb");
+const mapKwCocoCsv = require("./projects/mapKwCocoCsv");
const mergeLocal = require("./projects/mergeLocal");
const removeAccess = require("./projects/removeAccess");
const transferAdmin = require("./projects/transferAdmin");
@@ -169,6 +170,8 @@ api.post("/import", importProject);
api.post("/api/projects/import-dataset", importDataset);
api.post("/api/projects/import-yolo", importYolo);
api.post("/api/projects/import-kwcoco", importKwCoco);
+api.post("/api/projects/map-kwcoco-csv", mapKwCocoCsv);
+api.post("/mapKwCocoCsv", mapKwCocoCsv);
api.post("/api/projects/import-ifcb", importIfcb);
api.post("/mergeLocal", mergeLocal);
api.post("/removeAccess", removeAccess);
diff --git a/routes/api/projectsFilter.js b/routes/api/projectsFilter.js
index 67c51a15..749e4a1c 100644
--- a/routes/api/projectsFilter.js
+++ b/routes/api/projectsFilter.js
@@ -207,18 +207,22 @@ async function getFilteredImagesApi(req, res) {
const pdb = new sqlite3.Database(db_path, (err) => {
if (err) return reject(err);
- const query = `
- SELECT Images.IName, Images.reviewImage, Images.validateImage, COUNT(Labels.LID) AS numLabels
- FROM Images
- LEFT JOIN Labels ON Images.IName = Labels.IName
- GROUP BY Images.IName
- `;
+ pdb.run("ALTER TABLE Images ADD COLUMN reviewImage INTEGER NOT NULL DEFAULT 0", () => {
+ pdb.run("ALTER TABLE Images ADD COLUMN validateImage INTEGER NOT NULL DEFAULT 0", () => {
+ const query = `
+ SELECT Images.IName, Images.reviewImage, Images.validateImage, COUNT(Labels.LID) AS numLabels
+ FROM Images
+ LEFT JOIN Labels ON Images.IName = Labels.IName
+ GROUP BY Images.IName
+ `;
- pdb.all(query, [], (err, rows) => {
- pdb.close();
- if (err) return reject(err);
- images = rows || [];
- resolve();
+ pdb.all(query, [], (err, rows) => {
+ pdb.close();
+ if (err) return reject(err);
+ images = rows || [];
+ resolve();
+ });
+ });
});
});
});
diff --git a/routes/pages/getProjectPage.js b/routes/pages/getProjectPage.js
index 6ce26242..2b77decb 100644
--- a/routes/pages/getProjectPage.js
+++ b/routes/pages/getProjectPage.js
@@ -60,6 +60,15 @@ async function getProjectPage(req, res) {
global.logger.info("Connected to pdb.")
});
+ pdb.runAsync = function(sql, params) {
+ var that = this;
+ return new Promise(function(resolve) {
+ that.run(sql, params || [], function() {
+ resolve();
+ });
+ });
+ };
+
pdb.allAsync = function(sql) {
var that = this;
return new Promise(function(resolve, reject) {
@@ -75,6 +84,10 @@ async function getProjectPage(req, res) {
});
};
+ // Ensure schema compatibility for legacy project databases
+ await pdb.runAsync("ALTER TABLE Images ADD COLUMN reviewImage INTEGER NOT NULL DEFAULT 0");
+ await pdb.runAsync("ALTER TABLE Images ADD COLUMN validateImage INTEGER NOT NULL DEFAULT 0");
+
var rawImages = await pdb.allAsync(
"SELECT Images.IName, Images.reviewImage, Images.validateImage, COUNT(Labels.LID) AS numLabels " +
"FROM Images LEFT JOIN Labels ON Images.IName = Labels.IName " +
diff --git a/routes/pages/getYoloXTrainingSettingsPage.js b/routes/pages/getYoloXTrainingSettingsPage.js
index 79bfe3fd..5a38f5a4 100644
--- a/routes/pages/getYoloXTrainingSettingsPage.js
+++ b/routes/pages/getYoloXTrainingSettingsPage.js
@@ -78,12 +78,12 @@ async function getYoloXInferencePage(req, res) {
fs.mkdirSync(log_path);
fs.mkdirSync(python_path);
fs.mkdirSync(weights_path);
- fs.writeFile(python_path_file, "", function (err) {
+ fs.writeFile(python_path_file, "", function(err) {
if (err) {
global.logger.error(err);
}
});
- fs.writeFile(yolovx_path_file, "", function (err) {
+ fs.writeFile(yolovx_path_file, "", function(err) {
if (err) {
global.logger.error(err);
}
@@ -91,7 +91,7 @@ async function getYoloXInferencePage(req, res) {
} else if (!fs.existsSync(weights_path)) {
fs.mkdirSync(weights_path);
} else if (!fs.existsSync(yolovx_path_file)) {
- fs.writeFile(yolovx_path_file, "", function (err) {
+ fs.writeFile(yolovx_path_file, "", function(err) {
if (err) {
global.logger.error(err);
}
@@ -107,10 +107,10 @@ async function getYoloXInferencePage(req, res) {
});
// create async database object functions
- tdb.getAsync = function (sql) {
+ tdb.getAsync = function(sql) {
var that = this;
- return new Promise(function (resolve, reject) {
- that.get(sql, function (err, row) {
+ return new Promise(function(resolve, reject) {
+ that.get(sql, function(err, row) {
if (err) {
global.logger.error("runAsync ERROR!", err)
reject(err);
@@ -120,10 +120,10 @@ async function getYoloXInferencePage(req, res) {
global.logger.error(err);
});
};
- tdb.allAsync = function (sql) {
+ tdb.allAsync = function(sql) {
var that = this;
- return new Promise(function (resolve, reject) {
- that.all(sql, function (err, row) {
+ return new Promise(function(resolve, reject) {
+ that.all(sql, function(err, row) {
if (err) {
global.logger.error("runAsync ERROR!", err)
reject(err);
@@ -148,7 +148,7 @@ async function getYoloXInferencePage(req, res) {
try {
var countsResult = await queries.project.getClassLabelCounts(project_path);
if (countsResult && countsResult.rows) {
- countsResult.rows.forEach(function (row) {
+ countsResult.rows.forEach(function(row) {
classLabelCounts[row.CName] = row.labelCount;
});
}
@@ -161,7 +161,7 @@ async function getYoloXInferencePage(req, res) {
try {
var imageCountsResult = await queries.project.getClassImageCounts(project_path);
if (imageCountsResult && imageCountsResult.rows) {
- imageCountsResult.rows.forEach(function (row) {
+ imageCountsResult.rows.forEach(function(row) {
classImageCounts[row.CName] = row.imageCount;
});
}
@@ -169,7 +169,7 @@ async function getYoloXInferencePage(req, res) {
global.logger.error(err);
}
- results2 = results2.map(function (cls) {
+ results2 = results2.map(function(cls) {
return Object.assign({}, cls, {
labelCount: classLabelCounts[cls.CName] || 0,
imageCount: classImageCounts[cls.CName] || 0,
@@ -224,13 +224,13 @@ async function getYoloXInferencePage(req, res) {
weight = [];
run_path = `${log_path}${runs[i]}/`;
run_paths.push(run_path);
-
+
// get all files for each run (including subdirectories)
var runFiles = [];
-
+
// Read main directory files
logs = await readdirAsync(`${run_path}`);
-
+
// Add files from main directory
for (var j = 0; j < logs.length; j++) {
var filePath = run_path + logs[j];
@@ -249,7 +249,7 @@ async function getYoloXInferencePage(req, res) {
global.logger.debug("Error reading file stats:", err);
}
}
-
+
// Read subdirectories for additional files (like plots, results, etc.)
// We need to go deeper since YOLO puts training files in train/ subdirectory
for (var j = 0; j < logs.length; j++) {
@@ -301,9 +301,9 @@ async function getYoloXInferencePage(req, res) {
global.logger.debug("Error checking directory:", err);
}
}
-
+
all_run_files.push(runFiles);
-
+
// get index of log file
log_idx = logs.indexOf(`${runs[i]}.log`);
// get log file for each run
@@ -369,7 +369,7 @@ async function getYoloXInferencePage(req, res) {
}
// close the database
- tdb.close(function (err) {
+ tdb.close(function(err) {
if (err) {
global.logger.error(err);
} else {
diff --git a/routes/projects/mapKwCocoCsv.js b/routes/projects/mapKwCocoCsv.js
new file mode 100644
index 00000000..6f09b827
--- /dev/null
+++ b/routes/projects/mapKwCocoCsv.js
@@ -0,0 +1,127 @@
+const path = require('path');
+const fs = require('fs');
+const parseKwCocoCsv = require('../../utils/parseKwCocoCsv');
+const parseKwCocoJson = require('../../utils/parseKwCocoJson');
+const queries = require('../../queries/queries');
+const { Client } = require('../../queries/client');
+
+async function mapKwCocoCsv(req, res) {
+ try {
+ const projectName = req.body.PName || req.body.project_name || req.body.projectName;
+ const admin = req.body.Admin || req.cookies?.Username || 'admin';
+
+ if (!projectName) {
+ return res.status(400).json({ success: false, message: 'Project name is required.' });
+ }
+
+ if (!req.files || Object.keys(req.files).length === 0) {
+ return res.status(400).json({ success: false, message: 'No annotation file was uploaded.' });
+ }
+
+ const uploadedFile = req.files.kwcoco_csv || req.files.kwcoco_json || req.files.csv_file
+ || req.files.json_file || req.files.upload_csv || req.files.upload_json || Object.values(req.files)[0];
+ if (!uploadedFile) {
+ return res.status(400).json({ success: false, message: 'Invalid file upload payload.' });
+ }
+
+ const fileContent = uploadedFile.data ? uploadedFile.data.toString('utf8') : fs.readFileSync(uploadedFile.tempFilePath, 'utf8');
+
+ const ext = path.extname(uploadedFile.name || '').toLowerCase();
+ const trimmedContent = fileContent.trim();
+ const isJson = ext === '.json' || (ext !== '.csv' && (trimmedContent.startsWith('{') || trimmedContent.startsWith('[')));
+
+ const parsedAnnotations = isJson ? parseKwCocoJson(fileContent) : parseKwCocoCsv(fileContent);
+ if (parsedAnnotations.length === 0) {
+ return res.status(400).json({ success: false, message: 'No valid KW COCO annotations found in file.' });
+ }
+
+ const mainPath = path.join(__dirname, '..', '..', 'public', 'projects');
+ const projectPath = path.join(mainPath, `${admin}-${projectName}`);
+
+ if (!fs.existsSync(projectPath)) {
+ return res.status(404).json({ success: false, message: `Project path not found: ${admin}-${projectName}` });
+ }
+
+ const dbPath = path.join(projectPath, `${projectName}.db`);
+ if (!global.projectDbClients[projectPath]) {
+ global.projectDbClients[projectPath] = new Client(dbPath);
+ const client = global.projectDbClients[projectPath];
+ if (typeof client.open === 'function') {
+ client.open();
+ }
+ }
+
+ // Migrate DB if needed
+ await queries.project.migrateProjectDb(projectPath);
+
+ // 1. Ensure all referenced classes exist in Classes table
+ const existingClassResult = await queries.project.getAllClasses(projectPath);
+ const existingClassRows = existingClassResult?.rows || [];
+ const existingClassSet = new Set(existingClassRows.map(c => c.CName));
+
+ const uniqueClasses = new Set(parsedAnnotations.map(a => a.className));
+ let classesAdded = 0;
+
+ for (const cname of uniqueClasses) {
+ if (!existingClassSet.has(cname)) {
+ await queries.project.createClass(projectPath, cname);
+ existingClassSet.add(cname);
+ classesAdded++;
+ }
+ }
+
+ // 2. Ensure referenced images exist in Images table
+ const existingImageResult = await queries.project.getAllImages(projectPath);
+ const existingImageRows = existingImageResult?.rows || [];
+ const existingImageSet = new Set(existingImageRows.map(i => i.IName));
+
+ const uniqueImages = new Set(parsedAnnotations.map(a => a.filename));
+ let imagesRegistered = 0;
+
+ for (const iname of uniqueImages) {
+ if (!existingImageSet.has(iname)) {
+ await queries.project.sql(projectPath, "INSERT OR IGNORE INTO Images (IName, reviewImage, validateImage) VALUES (?, 0, 0)", [iname]);
+ existingImageSet.add(iname);
+ imagesRegistered++;
+ }
+ }
+
+ // 3. Get current max LID in Labels table
+ const maxLidResult = await queries.project.getMaxLabelId(projectPath);
+ const maxLidRows = maxLidResult?.rows || [];
+ let nextLid = 1;
+ if (maxLidRows.length > 0 && maxLidRows[0].LID) {
+ nextLid = maxLidRows[0].LID + 1;
+ }
+
+ // 4. Insert labels
+ let labelsInserted = 0;
+ for (const ann of parsedAnnotations) {
+ await queries.project.createLabel(
+ projectPath,
+ nextLid++,
+ ann.className,
+ ann.x,
+ ann.y,
+ ann.w,
+ ann.h,
+ ann.filename
+ );
+ labelsInserted++;
+ }
+
+ return res.json({
+ success: true,
+ message: `Successfully mapped ${labelsInserted} KW COCO annotations.`,
+ labelsInserted,
+ classesAdded,
+ imagesRegistered
+ });
+
+ } catch (err) {
+ console.error('Error mapping KW COCO CSV annotations:', err);
+ return res.status(500).json({ success: false, message: err.message || 'Internal server error mapping KW COCO CSV.' });
+ }
+}
+
+module.exports = mapKwCocoCsv;
diff --git a/tests/integration/legacyDbMigration.test.js b/tests/integration/legacyDbMigration.test.js
new file mode 100644
index 00000000..188ae6cf
--- /dev/null
+++ b/tests/integration/legacyDbMigration.test.js
@@ -0,0 +1,74 @@
+const path = require('path');
+const fs = require('fs');
+const sqlite3 = require('sqlite3').verbose();
+const queries = require('../../queries/queries');
+
+describe('Legacy Database Migration for Images validateImage / reviewImage columns', () => {
+ let tmpDir;
+ let dbPath;
+
+ beforeEach(() => {
+ global.projectDbClients = {};
+ tmpDir = path.join(__dirname, 'tmp_legacy_db_' + Date.now());
+ if (!fs.existsSync(tmpDir)) {
+ fs.mkdirSync(tmpDir, { recursive: true });
+ }
+ dbPath = path.join(tmpDir, 'test_legacy.db');
+ global.projectDbClients[dbPath] = new sqlite3.Database(dbPath);
+ });
+
+ afterEach(() => {
+ if (fs.existsSync(tmpDir)) {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ delete global.projectDbClients[dbPath];
+ });
+
+ test('migrateProjectDb adds missing reviewImage and validateImage columns to existing Images table', async () => {
+ const db = global.projectDbClients[dbPath];
+ db.run('CREATE TABLE Images (IName VARCHAR NOT NULL PRIMARY KEY)');
+ db.run("INSERT INTO Images (IName) VALUES ('test_image.jpg')");
+
+ // Run migrateProjectDb
+ await queries.project.migrateProjectDb(dbPath);
+
+ // Verify query succeeds
+ await new Promise((resolve, reject) => {
+ const pdb = global.projectDbClients[dbPath];
+ pdb.all(
+ 'SELECT Images.IName, Images.reviewImage, Images.validateImage FROM Images',
+ [],
+ (err, rows) => {
+ if (err) return reject(err);
+ expect(rows).toBeDefined();
+ resolve();
+ }
+ );
+ });
+ });
+
+ test('projectsFilter route handler query auto-migrates missing columns', async () => {
+ const db = global.projectDbClients[dbPath];
+ db.run('CREATE TABLE Images (IName VARCHAR NOT NULL PRIMARY KEY)');
+
+ // Simulate reading legacy db using the same logic as getProjectPage / projectsFilter
+ await new Promise((resolve, reject) => {
+ const pdb = global.projectDbClients[dbPath];
+ pdb.run("ALTER TABLE Images ADD COLUMN reviewImage INTEGER NOT NULL DEFAULT 0", () => {
+ pdb.run("ALTER TABLE Images ADD COLUMN validateImage INTEGER NOT NULL DEFAULT 0", () => {
+ const query = `
+ SELECT Images.IName, Images.reviewImage, Images.validateImage, COUNT(Labels.LID) AS numLabels
+ FROM Images
+ LEFT JOIN Labels ON Images.IName = Labels.IName
+ GROUP BY Images.IName
+ `;
+ pdb.all(query, [], (err, rows) => {
+ if (err) return reject(err);
+ expect(rows).toBeDefined();
+ resolve();
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/tests/integration/mapKwCocoCsv.test.js b/tests/integration/mapKwCocoCsv.test.js
new file mode 100644
index 00000000..5005498f
--- /dev/null
+++ b/tests/integration/mapKwCocoCsv.test.js
@@ -0,0 +1,123 @@
+const request = require('supertest');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const app = require('../../app');
+const queries = require('../../queries/queries');
+const { Client } = require('../../queries/client');
+
+describe('POST /api/projects/map-kwcoco-csv', () => {
+ let tmpDir;
+ let projectDir;
+ let originalProjectsPath;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'map-kwcoco-test-'));
+ projectDir = path.join(__dirname, '..', '..', 'public', 'projects', 'admin-testproj');
+ fs.mkdirSync(projectDir, { recursive: true });
+
+ const mockClient = {
+ open: jest.fn(),
+ all: jest.fn().mockImplementation((sql) => {
+ if (sql.includes('Classes')) return Promise.resolve({ success: true, rows: [] });
+ if (sql.includes('Images')) return Promise.resolve({ success: true, rows: [] });
+ if (sql.includes('Labels')) return Promise.resolve({ success: true, rows: [] });
+ return Promise.resolve({ success: true, rows: [] });
+ }),
+ get: jest.fn().mockResolvedValue({ success: true, row: null }),
+ run: jest.fn().mockResolvedValue({ success: true, changes: 1, lastID: 1 }),
+ };
+
+ global.projectDbClients = {
+ [projectDir]: mockClient
+ };
+ });
+
+ afterEach(() => {
+ fs.rmSync(projectDir, { recursive: true, force: true });
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ test('returns 400 when project name is missing', async () => {
+ const res = await request(app)
+ .post('/api/projects/map-kwcoco-csv')
+ .set('Cookie', ['Username=admin']);
+
+ expect(res.statusCode).toBe(400);
+ expect(res.body.success).toBe(false);
+ expect(res.body.message).toMatch(/Project name is required/i);
+ });
+
+ test('returns 400 when no annotation file is uploaded', async () => {
+ const res = await request(app)
+ .post('/api/projects/map-kwcoco-csv')
+ .field('PName', 'testproj')
+ .field('Admin', 'admin');
+
+ expect(res.statusCode).toBe(400);
+ expect(res.body.success).toBe(false);
+ expect(res.body.message).toMatch(/No annotation file was uploaded/i);
+ });
+
+ test('returns 404 when project path does not exist', async () => {
+ const csvContent = 'filename,class,xmin,ymin,xmax,ymax\nimg1.jpg,dolphin,10,10,50,50';
+ const csvPath = path.join(tmpDir, 'test.csv');
+ fs.writeFileSync(csvPath, csvContent);
+
+ const res = await request(app)
+ .post('/api/projects/map-kwcoco-csv')
+ .field('PName', 'nonexistent_project')
+ .field('Admin', 'admin')
+ .attach('kwcoco_csv', csvPath);
+
+ expect(res.statusCode).toBe(404);
+ expect(res.body.success).toBe(false);
+ });
+
+ test('successfully maps KW COCO CSV annotations', async () => {
+ const csvContent = `filename,class,xmin,ymin,xmax,ymax
+img1.jpg,dolphin,10,20,100,150
+img2.jpg,shark,30,40,80,120`;
+ const csvPath = path.join(tmpDir, 'test.csv');
+ fs.writeFileSync(csvPath, csvContent);
+
+ const res = await request(app)
+ .post('/api/projects/map-kwcoco-csv')
+ .field('PName', 'testproj')
+ .field('Admin', 'admin')
+ .attach('kwcoco_csv', csvPath);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.labelsInserted).toBe(2);
+ });
+
+ test('successfully maps KW COCO JSON annotations', async () => {
+ const jsonContent = JSON.stringify({
+ images: [
+ { id: 1, file_name: 'img1.jpg' },
+ { id: 2, file_name: 'img2.jpg' }
+ ],
+ annotations: [
+ { id: 1, image_id: 1, category_id: 1, bbox: [10, 20, 90, 130] },
+ { id: 2, image_id: 2, category_id: 2, bbox: [30, 40, 50, 80] }
+ ],
+ categories: [
+ { id: 1, name: 'dolphin' },
+ { id: 2, name: 'shark' }
+ ]
+ });
+ const jsonPath = path.join(tmpDir, 'test.json');
+ fs.writeFileSync(jsonPath, jsonContent);
+
+ const res = await request(app)
+ .post('/api/projects/map-kwcoco-csv')
+ .field('PName', 'testproj')
+ .field('Admin', 'admin')
+ .attach('kwcoco_json', jsonPath);
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.labelsInserted).toBe(2);
+ });
+});
diff --git a/tests/integration/spinnerViews.test.js b/tests/integration/spinnerViews.test.js
new file mode 100644
index 00000000..53912bf5
--- /dev/null
+++ b/tests/integration/spinnerViews.test.js
@@ -0,0 +1,75 @@
+jest.mock('sqlite3', () => {
+ const mockDb = {
+ run: jest.fn((...cbArgs) => {
+ const cb = cbArgs[cbArgs.length - 1];
+ if (typeof cb === 'function') cb(null);
+ return { lastID: 1, changes: 1 };
+ }),
+ get: jest.fn((...cbArgs) => {
+ const cb = cbArgs[cbArgs.length - 1];
+ if (typeof cb === 'function') cb(null, { user: 'testuser' });
+ }),
+ all: jest.fn((...cbArgs) => {
+ const cb = cbArgs[cbArgs.length - 1];
+ if (typeof cb === 'function') cb(null, []);
+ }),
+ close: jest.fn((cb) => cb && cb()),
+ };
+ const mockModule = {
+ OPEN_CREATE: 1,
+ OPEN_READWRITE: 2,
+ OPEN_READONLY: 1,
+ Database: jest.fn((...args) => {
+ const cb = args[args.length - 1];
+ if (typeof cb === 'function') cb(null);
+ return mockDb;
+ }),
+ verbose: jest.fn().mockImplementation(() => mockModule),
+ };
+ return mockModule;
+});
+
+const request = require('supertest');
+const app = require('../../app');
+
+describe('LoadingSpinner Integration in EJS Views', () => {
+ beforeAll(() => {
+ global.db = {
+ runAsync: jest.fn().mockResolvedValue(undefined),
+ getAsync: jest.fn().mockResolvedValue({ user: 'testuser' }),
+ allAsync: jest.fn().mockResolvedValue([]),
+ };
+ });
+
+ it('serves public/js/spinner.js static file', async () => {
+ const res = await request(app).get('/js/spinner.js');
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toMatch(/javascript/);
+ expect(res.text).toContain('LoadingSpinner');
+ });
+
+ it('includes /js/spinner.js in pages using header.ejs', async () => {
+ const res = await request(app)
+ .get('/create')
+ .set('Cookie', ['user=testuser']);
+ expect(res.statusCode).toBe(200);
+ expect(res.text).toContain('');
+ });
+
+ it('contains LoadingSpinner integrations in /create view', async () => {
+ const res = await request(app)
+ .get('/create')
+ .set('Cookie', ['user=testuser']);
+ expect(res.statusCode).toBe(200);
+ expect(res.text).toContain('LoadingSpinner.show(submitBtn');
+ expect(res.text).toContain('s3SubmitBtn');
+ });
+
+ it('contains LoadingSpinner integrations in /createClassification view', async () => {
+ const res = await request(app)
+ .get('/createClassification')
+ .set('Cookie', ['user=testuser']);
+ expect(res.statusCode).toBe(200);
+ expect(res.text).toContain('LoadingSpinner.show(submitBtn');
+ });
+});
diff --git a/tests/integration/yoloTrainingClassClamp.test.js b/tests/integration/yoloTrainingClassClamp.test.js
index b36c6b6a..6ff16f1a 100644
--- a/tests/integration/yoloTrainingClassClamp.test.js
+++ b/tests/integration/yoloTrainingClassClamp.test.js
@@ -9,117 +9,117 @@ const fs = require('fs');
const path = require('path');
function extractClassSelectionScript() {
- const templatePath = path.join(__dirname, '../../views/training/yolovXTrainingSettings.ejs');
- const template = fs.readFileSync(templatePath, 'utf8');
+ const templatePath = path.join(__dirname, '../../views/training/yolovXTrainingSettings.ejs');
+ const template = fs.readFileSync(templatePath, 'utf8');
- const startMarker = '// Class selection helper buttons';
- const endMarker = '// Split ratio summary update';
- const start = template.indexOf(startMarker);
- const end = template.indexOf(endMarker);
+ const startMarker = '// Class selection helper buttons';
+ const endMarker = '// Split ratio summary update';
+ const start = template.indexOf(startMarker);
+ const end = template.indexOf(endMarker);
- if (start === -1 || end === -1) {
- throw new Error('Could not locate class selection script block in template');
- }
+ if (start === -1 || end === -1) {
+ throw new Error('Could not locate class selection script block in template');
+ }
- return template.slice(start, end);
+ return template.slice(start, end);
}
function makeCheckbox(count, checked) {
- return {
- checked,
- getAttribute: jest.fn(() => String(count)),
- };
+ return {
+ checked,
+ getAttribute: jest.fn(() => String(count)),
+ };
}
function makeFakeDocument(checkboxes) {
- const elements = {};
- const listeners = {};
-
- function makeButtonLike(id) {
- const el = {
- addEventListener: jest.fn((evt, handler) => {
- listeners[id] = listeners[id] || {};
- listeners[id][evt] = handler;
- }),
- };
- elements[id] = el;
- return el;
- }
-
- makeButtonLike('selectAllClasses');
- makeButtonLike('deselectAllClasses');
-
- const minInput = makeButtonLike('minClassImages');
- minInput.value = '';
-
- const doc = {
- getElementById: jest.fn((id) => elements[id]),
- querySelectorAll: jest.fn((selector) => {
- if (selector === '.class-checkbox') {
- return {
- forEach: (fn) => checkboxes.forEach(fn),
+ const elements = {};
+ const listeners = {};
+
+ function makeButtonLike(id) {
+ const el = {
+ addEventListener: jest.fn((evt, handler) => {
+ listeners[id] = listeners[id] || {};
+ listeners[id][evt] = handler;
+ }),
};
- }
- return { forEach: () => {} };
- }),
- };
+ elements[id] = el;
+ return el;
+ }
- return { doc, listeners, minInput };
-}
+ makeButtonLike('selectAllClasses');
+ makeButtonLike('deselectAllClasses');
-describe('yolovXTrainingSettings.ejs class selection + minimum images clamp script', () => {
- let checkboxes;
- let doc;
- let listeners;
- let minInput;
-
- beforeEach(() => {
- // Mirrors data-image-count rendered per checkbox: person=10, car=3, dog=0
- checkboxes = [
- makeCheckbox(10, true),
- makeCheckbox(3, true),
- makeCheckbox(0, true),
- ];
- ({ doc, listeners, minInput } = makeFakeDocument(checkboxes));
-
- const script = extractClassSelectionScript();
- // eslint-disable-next-line no-new-func
- const run = new Function('document', script);
- run(doc);
- });
-
- it('does nothing when the minimum images threshold is empty/zero', () => {
+ const minInput = makeButtonLike('minClassLabels');
minInput.value = '';
- listeners.minClassImages.input();
-
- expect(checkboxes.map((cb) => cb.checked)).toEqual([true, true, true]);
- });
-
- it('unchecks only classes below the configured minimum on input', () => {
- minInput.value = '5';
- listeners.minClassImages.input();
-
- expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]);
- });
- it('re-applies the clamp when Select All is clicked, so it cannot bypass the minimum', () => {
- minInput.value = '5';
- listeners.minClassImages.input();
- expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]);
-
- listeners.selectAllClasses.click();
-
- // Select All checks everything first, then the clamp must immediately re-uncheck
- // the classes that don't meet the threshold.
- expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]);
- });
-
- it('Deselect All unchecks everything regardless of the clamp threshold', () => {
- minInput.value = '5';
- listeners.minClassImages.input();
+ const doc = {
+ getElementById: jest.fn((id) => elements[id]),
+ querySelectorAll: jest.fn((selector) => {
+ if (selector === '.class-checkbox') {
+ return {
+ forEach: (fn) => checkboxes.forEach(fn),
+ };
+ }
+ return { forEach: () => { } };
+ }),
+ };
- listeners.deselectAllClasses.click();
+ return { doc, listeners, minInput };
+}
- expect(checkboxes.map((cb) => cb.checked)).toEqual([false, false, false]);
- });
+describe('yolovXTrainingSettings.ejs class selection + minimum images clamp script', () => {
+ let checkboxes;
+ let doc;
+ let listeners;
+ let minInput;
+
+ beforeEach(() => {
+ // Mirrors data-image-count rendered per checkbox: person=10, car=3, dog=0
+ checkboxes = [
+ makeCheckbox(10, true),
+ makeCheckbox(3, true),
+ makeCheckbox(0, true),
+ ];
+ ({ doc, listeners, minInput } = makeFakeDocument(checkboxes));
+
+ const script = extractClassSelectionScript();
+ // eslint-disable-next-line no-new-func
+ const run = new Function('document', script);
+ run(doc);
+ });
+
+ it('does nothing when the minimum images threshold is empty/zero', () => {
+ minInput.value = '';
+ listeners.minClassLabels.input();
+
+ expect(checkboxes.map((cb) => cb.checked)).toEqual([true, true, true]);
+ });
+
+ it('unchecks only classes below the configured minimum on input', () => {
+ minInput.value = '5';
+ listeners.minClassLabels.input();
+
+ expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]);
+ });
+
+ it('re-applies the clamp when Select All is clicked, so it cannot bypass the minimum', () => {
+ minInput.value = '5';
+ listeners.minClassLabels.input();
+ expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]);
+
+ listeners.selectAllClasses.click();
+
+ // Select All checks everything first, then the clamp must immediately re-uncheck
+ // the classes that don't meet the threshold.
+ expect(checkboxes.map((cb) => cb.checked)).toEqual([true, false, false]);
+ });
+
+ it('Deselect All unchecks everything regardless of the clamp threshold', () => {
+ minInput.value = '5';
+ listeners.minClassLabels.input();
+
+ listeners.deselectAllClasses.click();
+
+ expect(checkboxes.map((cb) => cb.checked)).toEqual([false, false, false]);
+ });
});
diff --git a/tests/unit/parseKwCocoCsv.test.js b/tests/unit/parseKwCocoCsv.test.js
new file mode 100644
index 00000000..3f91e614
--- /dev/null
+++ b/tests/unit/parseKwCocoCsv.test.js
@@ -0,0 +1,52 @@
+const parseKwCocoCsv = require('../../utils/parseKwCocoCsv');
+
+describe('parseKwCocoCsv', () => {
+ test('returns empty array for invalid or empty input', () => {
+ expect(parseKwCocoCsv(null)).toEqual([]);
+ expect(parseKwCocoCsv('')).toEqual([]);
+ expect(parseKwCocoCsv(' ')).toEqual([]);
+ });
+
+ test('parses CSV with header containing filename, class, xmin, ymin, xmax, ymax', () => {
+ const csv = `filename,class,xmin,ymin,xmax,ymax
+image1.jpg,dolphin,10,20,100,150
+image2.png,blue whale,50,60,200,260`;
+
+ const result = parseKwCocoCsv(csv);
+ expect(result).toEqual([
+ { filename: 'image1.jpg', className: 'dolphin', x: 10, y: 20, w: 90, h: 130 },
+ { filename: 'image2.png', className: 'blue_whale', x: 50, y: 60, w: 150, h: 200 }
+ ]);
+ });
+
+ test('parses CSV with header containing x, y, w, h', () => {
+ const csv = `file_name,category,x,y,w,h
+path/to/img3.jpg,sea turtle,15,25,80,95`;
+
+ const result = parseKwCocoCsv(csv);
+ expect(result).toEqual([
+ { filename: 'img3.jpg', className: 'sea_turtle', x: 15, y: 25, w: 80, h: 95 }
+ ]);
+ });
+
+ test('parses positional CSV without header', () => {
+ const csv = `img4.jpg,fish,5,10,45,60`;
+
+ const result = parseKwCocoCsv(csv);
+ expect(result).toEqual([
+ { filename: 'img4.jpg', className: 'fish', x: 5, y: 10, w: 40, h: 50 }
+ ]);
+ });
+
+ test('ignores invalid rows and negative width/height', () => {
+ const csv = `filename,class,xmin,ymin,xmax,ymax
+bad1.jpg,fish,100,100,50,50
+bad2.jpg,fish,invalid,10,20,30
+good.jpg,shark,10,10,30,30`;
+
+ const result = parseKwCocoCsv(csv);
+ expect(result).toEqual([
+ { filename: 'good.jpg', className: 'shark', x: 10, y: 10, w: 20, h: 20 }
+ ]);
+ });
+});
diff --git a/tests/unit/parseKwCocoJson.test.js b/tests/unit/parseKwCocoJson.test.js
new file mode 100644
index 00000000..cbb6b0de
--- /dev/null
+++ b/tests/unit/parseKwCocoJson.test.js
@@ -0,0 +1,64 @@
+const parseKwCocoJson = require('../../utils/parseKwCocoJson');
+
+describe('parseKwCocoJson', () => {
+ test('returns empty array for invalid or empty input', () => {
+ expect(parseKwCocoJson(null)).toEqual([]);
+ expect(parseKwCocoJson('')).toEqual([]);
+ expect(parseKwCocoJson('not json')).toEqual([]);
+ expect(parseKwCocoJson('{}')).toEqual([]);
+ });
+
+ test('parses standard COCO-style JSON with images/annotations/categories', () => {
+ const json = JSON.stringify({
+ images: [
+ { id: 1, file_name: 'image1.jpg' },
+ { id: 2, file_name: 'path/to/image2.png' }
+ ],
+ annotations: [
+ { id: 1, image_id: 1, category_id: 10, bbox: [10, 20, 90, 130] },
+ { id: 2, image_id: 2, category_id: 11, bbox: [50, 60, 150, 200] }
+ ],
+ categories: [
+ { id: 10, name: 'dolphin' },
+ { id: 11, name: 'blue whale' }
+ ]
+ });
+
+ const result = parseKwCocoJson(json);
+ expect(result).toEqual([
+ { filename: 'image1.jpg', className: 'dolphin', x: 10, y: 20, w: 90, h: 130 },
+ { filename: 'image2.png', className: 'blue_whale', x: 50, y: 60, w: 150, h: 200 }
+ ]);
+ });
+
+ test('skips annotations referencing unknown images or categories', () => {
+ const json = JSON.stringify({
+ images: [{ id: 1, file_name: 'good.jpg' }],
+ annotations: [
+ { id: 1, image_id: 1, category_id: 5, bbox: [10, 10, 20, 20] },
+ { id: 2, image_id: 999, category_id: 5, bbox: [10, 10, 20, 20] },
+ { id: 3, image_id: 1, category_id: 999, bbox: [10, 10, 20, 20] }
+ ],
+ categories: [{ id: 5, name: 'fish' }]
+ });
+
+ const result = parseKwCocoJson(json);
+ expect(result).toEqual([
+ { filename: 'good.jpg', className: 'fish', x: 10, y: 10, w: 20, h: 20 }
+ ]);
+ });
+
+ test('ignores annotations with invalid or non-positive bbox dimensions', () => {
+ const json = JSON.stringify({
+ images: [{ id: 1, file_name: 'img.jpg' }],
+ annotations: [
+ { id: 1, image_id: 1, category_id: 1, bbox: [0, 0, 0, 0] },
+ { id: 2, image_id: 1, category_id: 1, bbox: ['a', 'b', 'c', 'd'] },
+ { id: 3, image_id: 1, category_id: 1, bbox: [1, 2, 3] }
+ ],
+ categories: [{ id: 1, name: 'shark' }]
+ });
+
+ expect(parseKwCocoJson(json)).toEqual([]);
+ });
+});
diff --git a/tests/unit/spinner.test.js b/tests/unit/spinner.test.js
new file mode 100644
index 00000000..15b4f3b8
--- /dev/null
+++ b/tests/unit/spinner.test.js
@@ -0,0 +1,81 @@
+/**
+ * Unit tests for public/js/spinner.js LoadingSpinner helper
+ */
+const LoadingSpinner = require('../../public/js/spinner.js');
+
+describe('LoadingSpinner helper', () => {
+ let mockElement;
+
+ beforeEach(() => {
+ // Mock DOM element environment
+ mockElement = {
+ tagName: 'BUTTON',
+ type: 'submit',
+ disabled: false,
+ innerHTML: 'Submit Form',
+ value: 'Submit Form',
+ classList: {
+ add: jest.fn(),
+ remove: jest.fn()
+ },
+ dataset: {},
+ querySelectorAll: jest.fn().mockReturnValue([])
+ };
+ });
+
+ test('exports LoadingSpinner object with required methods', () => {
+ expect(LoadingSpinner).toBeDefined();
+ expect(typeof LoadingSpinner.show).toBe('function');
+ expect(typeof LoadingSpinner.hide).toBe('function');
+ expect(typeof LoadingSpinner.showOverlay).toBe('function');
+ expect(typeof LoadingSpinner.hideOverlay).toBe('function');
+ expect(typeof LoadingSpinner.wrapFetch).toBe('function');
+ });
+
+ test('show() disables element and sets spinner HTML and loading class', () => {
+ LoadingSpinner.show(mockElement, 'Uploading...');
+
+ expect(mockElement.disabled).toBe(true);
+ expect(mockElement.dataset.njobvuOriginalHtml).toBe('Submit Form');
+ expect(mockElement.dataset.njobvuOriginalDisabled).toBe('false');
+ expect(mockElement.classList.add).toHaveBeenCalledWith('njobvu-btn-loading');
+ expect(mockElement.innerHTML).toContain('njobvu-spinner-inline');
+ expect(mockElement.innerHTML).toContain('Uploading...');
+ });
+
+ test('show() supports default message when string is omitted', () => {
+ LoadingSpinner.show(mockElement);
+ expect(mockElement.innerHTML).toContain('Processing...');
+ });
+
+ test('hide() restores original HTML, disabled status and removes loading class', () => {
+ LoadingSpinner.show(mockElement, 'Uploading...');
+ LoadingSpinner.hide(mockElement);
+
+ expect(mockElement.disabled).toBe(false);
+ expect(mockElement.innerHTML).toBe('Submit Form');
+ expect(mockElement.dataset.njobvuOriginalHtml).toBeUndefined();
+ expect(mockElement.dataset.njobvuOriginalDisabled).toBeUndefined();
+ expect(mockElement.classList.remove).toHaveBeenCalledWith('njobvu-btn-loading');
+ });
+
+ test('wrapFetch() executes promise and automatically shows then hides spinner', async () => {
+ const fakeFetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
+
+ const res = await LoadingSpinner.wrapFetch(fakeFetch(), mockElement, 'Saving...');
+
+ expect(fakeFetch).toHaveBeenCalled();
+ expect(res).toEqual({ ok: true, json: expect.any(Function) });
+ expect(mockElement.disabled).toBe(false);
+ expect(mockElement.innerHTML).toBe('Submit Form');
+ });
+
+ test('wrapFetch() hides spinner even when promise rejects', async () => {
+ const fakeFetch = jest.fn().mockRejectedValue(new Error('Network error'));
+
+ await expect(LoadingSpinner.wrapFetch(fakeFetch(), mockElement, 'Saving...')).rejects.toThrow('Network error');
+
+ expect(mockElement.disabled).toBe(false);
+ expect(mockElement.innerHTML).toBe('Submit Form');
+ });
+});
diff --git a/utils/parseKwCocoCsv.js b/utils/parseKwCocoCsv.js
new file mode 100644
index 00000000..b09bf5a3
--- /dev/null
+++ b/utils/parseKwCocoCsv.js
@@ -0,0 +1,141 @@
+const path = require('path');
+
+/**
+ * Parses KW COCO CSV annotation strings or file contents into structured bounding box records.
+ *
+ * Supports header-based CSVs (filename/file_name, class/category/label, xmin/x/bbox_x, ymin/y/bbox_y, xmax/w/bbox_w, ymax/h/bbox_h)
+ * and positional/VIAME CSVs (filename, class, xmin/x, ymin/y, xmax/w, ymax/h).
+ *
+ * @param {string} csvContent - Raw CSV string content
+ * @returns {Array<{filename: string, className: string, x: number, y: number, w: number, h: number}>} Parsed annotation objects
+ */
+function parseKwCocoCsv(csvContent) {
+ if (!csvContent || typeof csvContent !== 'string') {
+ return [];
+ }
+
+ const lines = csvContent
+ .split(/\r?\n/)
+ .map(line => line.trim())
+ .filter(line => line.length > 0 && !line.startsWith('#'));
+
+ if (lines.length === 0) {
+ return [];
+ }
+
+ const firstLine = lines[0];
+ const rawTokens = firstLine.split(',').map(t => t.trim());
+ const lowerTokens = rawTokens.map(t => t.toLowerCase());
+
+ const hasHeader = lowerTokens.some(t =>
+ ['filename', 'file_name', 'file', 'image', 'class', 'category', 'label', 'xmin', 'x', 'ymin', 'y', 'xmax', 'w', 'ymax', 'h', 'bbox_x'].includes(t)
+ );
+
+ let filenameIdx = 0;
+ let classIdx = 1;
+ let xIdx = 2;
+ let yIdx = 3;
+ let wOrXmaxIdx = 4;
+ let hOrYmaxIdx = 5;
+ let isWidthHeight = false;
+
+ let startLine = 0;
+
+ if (hasHeader) {
+ startLine = 1;
+
+ filenameIdx = lowerTokens.findIndex(t => ['filename', 'file_name', 'file', 'image', 'iname', 'image_name'].includes(t));
+ if (filenameIdx === -1) filenameIdx = 0;
+
+ classIdx = lowerTokens.findIndex(t => ['class', 'category', 'label', 'cname', 'class_name'].includes(t));
+ if (classIdx === -1) classIdx = 1;
+
+ xIdx = lowerTokens.findIndex(t => ['xmin', 'x', 'left_x', 'left', 'bbox_x', 'tl_x'].includes(t));
+ if (xIdx === -1) xIdx = 2;
+
+ yIdx = lowerTokens.findIndex(t => ['ymin', 'y', 'top_y', 'top', 'bbox_y', 'tl_y'].includes(t));
+ if (yIdx === -1) yIdx = 3;
+
+ const wIdx = lowerTokens.findIndex(t => ['w', 'width', 'box_w', 'bbox_w'].includes(t));
+ const xmaxIdx = lowerTokens.findIndex(t => ['xmax', 'right_x', 'right', 'br_x'].includes(t));
+
+ if (wIdx !== -1) {
+ wOrXmaxIdx = wIdx;
+ isWidthHeight = true;
+ } else if (xmaxIdx !== -1) {
+ wOrXmaxIdx = xmaxIdx;
+ isWidthHeight = false;
+ } else {
+ wOrXmaxIdx = 4;
+ }
+
+ const hIdx = lowerTokens.findIndex(t => ['h', 'height', 'box_h', 'bbox_h'].includes(t));
+ const ymaxIdx = lowerTokens.findIndex(t => ['ymax', 'bottom_y', 'bottom', 'br_y'].includes(t));
+
+ if (hIdx !== -1) {
+ hOrYmaxIdx = hIdx;
+ } else if (ymaxIdx !== -1) {
+ hOrYmaxIdx = ymaxIdx;
+ } else {
+ hOrYmaxIdx = 5;
+ }
+ }
+
+ const results = [];
+
+ for (let i = startLine; i < lines.length; i++) {
+ const row = lines[i].split(',').map(col => col.trim());
+ if (row.length <= Math.max(filenameIdx, classIdx, xIdx, yIdx, wOrXmaxIdx, hOrYmaxIdx)) {
+ continue;
+ }
+
+ const rawFilename = row[filenameIdx];
+ const rawClass = row[classIdx];
+
+ if (!rawFilename || !rawClass) {
+ continue;
+ }
+
+ const filename = path.basename(rawFilename.replace(/\\/g, '/'));
+ const className = rawClass.replace(/\s+/g, '_');
+
+ const xVal = parseFloat(row[xIdx]);
+ const yVal = parseFloat(row[yIdx]);
+ const val4 = parseFloat(row[wOrXmaxIdx]);
+ const val5 = parseFloat(row[hOrYmaxIdx]);
+
+ if (isNaN(xVal) || isNaN(yVal) || isNaN(val4) || isNaN(val5)) {
+ continue;
+ }
+
+ let x = Math.round(xVal);
+ let y = Math.round(yVal);
+ let w = 0;
+ let h = 0;
+
+ if (isWidthHeight) {
+ w = Math.round(val4);
+ h = Math.round(val5);
+ } else {
+ w = Math.round(val4 - xVal);
+ h = Math.round(val5 - yVal);
+ }
+
+ if (w <= 0 || h <= 0) {
+ continue;
+ }
+
+ results.push({
+ filename,
+ className,
+ x,
+ y,
+ w,
+ h
+ });
+ }
+
+ return results;
+}
+
+module.exports = parseKwCocoCsv;
diff --git a/utils/parseKwCocoJson.js b/utils/parseKwCocoJson.js
new file mode 100644
index 00000000..f81d0b06
--- /dev/null
+++ b/utils/parseKwCocoJson.js
@@ -0,0 +1,92 @@
+const path = require('path');
+
+/**
+ * Parses KW COCO / COCO-style JSON annotation content into the same
+ * {filename, className, x, y, w, h} shape produced by parseKwCocoCsv, so both
+ * formats can feed the same downstream label-import pipeline.
+ *
+ * Expects the standard COCO structure: {images: [...], annotations: [...], categories: [...]}
+ * with annotations referencing images/categories by id and bbox as [x, y, w, h].
+ *
+ * @param {string} jsonContent - Raw JSON string content
+ * @returns {Array<{filename: string, className: string, x: number, y: number, w: number, h: number}>} Parsed annotation objects
+ */
+function parseKwCocoJson(jsonContent) {
+ if (!jsonContent || typeof jsonContent !== 'string') {
+ return [];
+ }
+
+ let data;
+ try {
+ data = JSON.parse(jsonContent);
+ } catch (err) {
+ return [];
+ }
+
+ if (!data || typeof data !== 'object') {
+ return [];
+ }
+
+ const images = Array.isArray(data.images) ? data.images : [];
+ const annotations = Array.isArray(data.annotations) ? data.annotations : [];
+ const categories = Array.isArray(data.categories) ? data.categories : [];
+
+ if (images.length === 0 || annotations.length === 0) {
+ return [];
+ }
+
+ const imageIdToFilename = new Map();
+ for (const img of images) {
+ if (!img || img.id == null) continue;
+ const rawName = img.file_name || img.filename || img.name;
+ if (!rawName) continue;
+ imageIdToFilename.set(img.id, path.basename(String(rawName).replace(/\\/g, '/')));
+ }
+
+ const categoryIdToName = new Map();
+ for (const cat of categories) {
+ if (!cat || cat.id == null) continue;
+ const rawName = cat.name || cat.category_name;
+ if (!rawName) continue;
+ categoryIdToName.set(cat.id, String(rawName).replace(/\s+/g, '_'));
+ }
+
+ const results = [];
+
+ for (const ann of annotations) {
+ if (!ann) continue;
+
+ const filename = imageIdToFilename.get(ann.image_id);
+ if (!filename) continue;
+
+ const className = categoryIdToName.get(ann.category_id);
+ if (!className) continue;
+
+ const bbox = ann.bbox;
+ if (!Array.isArray(bbox) || bbox.length < 4) continue;
+
+ const xVal = parseFloat(bbox[0]);
+ const yVal = parseFloat(bbox[1]);
+ const wVal = parseFloat(bbox[2]);
+ const hVal = parseFloat(bbox[3]);
+
+ if (isNaN(xVal) || isNaN(yVal) || isNaN(wVal) || isNaN(hVal)) {
+ continue;
+ }
+
+ const x = Math.round(xVal);
+ const y = Math.round(yVal);
+ const w = Math.round(wVal);
+ const h = Math.round(hVal);
+
+ if (w <= 0 || h <= 0) {
+ continue;
+ }
+
+ results.push({ filename, className, x, y, w, h });
+ }
+
+ return results;
+}
+
+module.exports = parseKwCocoJson;
diff --git a/views/create.ejs b/views/create.ejs
index 7562b8bc..f8802759 100644
--- a/views/create.ejs
+++ b/views/create.ejs
@@ -297,7 +297,7 @@
const submitBtn = document.getElementById('s3SubmitBtn');
const progress = document.getElementById('progress');
- submitBtn.style.display = "none";
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Creating project...");
try {
progress.textContent = "Creating project...";
@@ -316,6 +316,7 @@
}
progress.textContent = "Attaching S3 bucket...";
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Attaching S3 bucket...");
const attachRes = await fetch(`/api/v2/projects/${encodeURIComponent(currentUsername)}/${encodeURIComponent(projectName)}/s3-bucket`, {
method: 'POST',
@@ -336,6 +337,7 @@
}
progress.textContent = "Syncing images from S3...";
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Syncing images from S3...");
const syncRes = await fetch(`/api/v2/projects/${encodeURIComponent(currentUsername)}/${encodeURIComponent(projectName)}/s3-bucket/sync`, {
method: 'POST',
@@ -351,7 +353,7 @@
window.location.replace('/home');
} catch (err) {
progress.textContent = "";
- submitBtn.style.display = "inline-block";
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
alert("Something went wrong setting up the S3-backed project.\nReason: " + err.message);
}
}
@@ -375,23 +377,26 @@
const formData = new FormData(this);
const xhr = new XMLHttpRequest();
const progress = document.getElementById('progress');
+ const submitBtn = this.querySelector('input[type="submit"], button[type="submit"]');
progress.textContent = "Percent Uploaded: 0%";
- document.querySelectorAll('input[type="submit"]').forEach(btn => btn.style.display = "none");
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Uploading 0%...");
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progress.textContent = `Percent Uploaded: ${percent}%`;
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, `Uploading ${percent}%...`);
if (percent === 100) {
progress.textContent = "Upload Finished. Server processing upload. Please wait for the closing prompt.";
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Processing...");
}
}
});
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
- document.querySelectorAll('input[type="submit"]').forEach(btn => btn.style.display = "inline-block");
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
if (xhr.status === 200) {
alert("Project creation successful");
@@ -427,7 +432,7 @@
formData.append('frame_rate', frameRate);
} else {
progress.textContent = "";
- document.querySelectorAll('input[type="submit"]').forEach(btn => btn.style.display = "inline-block");
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
return;
}
}
@@ -449,7 +454,7 @@
formData.append('frame_rate', frameRate);
} else {
progress.textContent = "";
- document.querySelectorAll('input[type="submit"]').forEach(btn => btn.style.display = "inline-block");
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
return;
}
}
diff --git a/views/createClassification.ejs b/views/createClassification.ejs
index d217cf8f..ca9cf441 100644
--- a/views/createClassification.ejs
+++ b/views/createClassification.ejs
@@ -80,7 +80,8 @@
var messageDiv = document.getElementById('response-message');
messageDiv.innerHTML = 'Importing... Please wait.
';
-
+ var submitBtn = this.querySelector('button[type="submit"]');
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Importing...");
fetch('/api/projects/import-dataset', {
method: 'POST',
@@ -92,10 +93,12 @@
messageDiv.innerHTML = 'Import successful! Redirecting to home...
';
setTimeout(() => window.location.replace("/home"), 2000);
} else {
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
messageDiv.innerHTML = 'Error: ' + data.message + '
';
}
})
.catch(error => {
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
messageDiv.innerHTML = 'Error: ' + error + '
';
});
});
diff --git a/views/customTraining.ejs b/views/customTraining.ejs
index a5ce97ca..399bcf01 100644
--- a/views/customTraining.ejs
+++ b/views/customTraining.ejs
@@ -196,7 +196,8 @@
formData.append("PName", "<%= PName %>");
formData.append("Admin", "<%= Admin %>");
- console.log(files)
+ const submitBtn = event.target.querySelector('button[type="submit"]');
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Adding file...");
fetch(event.target.action, {
method: 'POST',
@@ -209,6 +210,7 @@
window.location.replace("/training?IDX=<%= IDX %>");
}).catch((error) => {
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
console.log(error);
});
});
@@ -232,6 +234,8 @@
formData.append("PName", "<%= PName %>");
formData.append("Admin", "<%= Admin %>");
+ const submitBtn = event.target.querySelector('button[type="submit"]');
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Uploading weights...");
fetch(event.target.action, {
method: 'POST',
@@ -244,6 +248,7 @@
window.location.replace("/training?IDX=<%= IDX %>");
}).catch((error) => {
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
console.log(error);
});
}
@@ -259,6 +264,8 @@
formData.append("PName", "<%= PName %>");
formData.append("Admin", "<%= Admin %>");
+ const submitBtn = event.target.querySelector('button[type="submit"]');
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Adding path...");
fetch(event.target.action, {
method: 'POST',
@@ -272,6 +279,7 @@
window.location.replace("/training?IDX=<%= IDX %>");
}).catch((error) => {
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
console.log(error);
});
});
@@ -302,6 +310,8 @@
formData.append("PName", "<%= PName %>");
formData.append("Admin", "<%= Admin %>");
+ const submitBtn = document.getElementById("Trainbtn");
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Starting training...");
fetch(event.target.action, {
method: 'POST',
@@ -315,6 +325,7 @@
window.location.replace("/training?IDX=<%= IDX %>");
}).catch((error) => {
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
console.log(error);
});
});
diff --git a/views/includes/header.ejs b/views/includes/header.ejs
index 8149a840..a6ef1ce8 100644
--- a/views/includes/header.ejs
+++ b/views/includes/header.ejs
@@ -18,6 +18,7 @@
+
diff --git a/views/inference.ejs b/views/inference.ejs
index 00f7f563..1fe1be21 100644
--- a/views/inference.ejs
+++ b/views/inference.ejs
@@ -450,8 +450,12 @@ document.addEventListener("click", async function (e) {
const runTimestamp = btn.dataset.timestamp;
const minConfidence = confInput.value;
- btn.disabled = true;
- btn.textContent = "Adding...";
+ if (window.LoadingSpinner) {
+ LoadingSpinner.show(btn, "Adding...");
+ } else {
+ btn.disabled = true;
+ btn.textContent = "Adding...";
+ }
try {
const response = await fetch("/inference/add-inference-run-to-dataset", {
@@ -476,8 +480,12 @@ document.addEventListener("click", async function (e) {
} catch (err) {
alert(`Request failed: ${err.message}`);
} finally {
- btn.disabled = false;
- btn.textContent = "Add to Training Set";
+ if (window.LoadingSpinner) {
+ LoadingSpinner.hide(btn);
+ } else {
+ btn.disabled = false;
+ btn.textContent = "Add to Training Set";
+ }
}
});
diff --git a/views/project.ejs b/views/project.ejs
index ee023a78..ae98969f 100644
--- a/views/project.ejs
+++ b/views/project.ejs
@@ -159,12 +159,15 @@
formData.append("PName", "<%= PName %>");
formData.append("Admin", "<%= Admin %>");
+ if (window.LoadingSpinner) LoadingSpinner.showOverlay("Deleting image...");
+
fetch('/deleteImage', {
method: 'POST',
body: formData
}).then(() => {
window.location.reload();
}).catch((error) => {
+ if (window.LoadingSpinner) LoadingSpinner.hideOverlay();
console.log(error);
});
}
diff --git a/views/settings/projSettings.ejs b/views/settings/projSettings.ejs
index 8f9d1f3c..1f791f8f 100644
--- a/views/settings/projSettings.ejs
+++ b/views/settings/projSettings.ejs
@@ -93,6 +93,31 @@
+
+
+ Pre-Processing: Map KW COCO Annotations
+
+
+ Upload a `.csv` or `.json` annotation file (KW COCO format) to map bounding box labels directly to project images.
+
+
+
+
Delete Images Without Label
<% if (typeof classes !== 'undefined' && classes.length > 0) { %>
<% for (var i = 0; i < classes.length; i++) { %>
-
-
+
+
<% } %>
<% } else { %>
@@ -282,6 +282,8 @@
formData.append("PName", "<%= PName %>");
formData.append("Admin", "<%= Admin %>");
+ const submitBtn = event.target.querySelector('button[type="submit"]');
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Uploading weights...");
fetch(event.target.action, {
method: 'POST',
@@ -294,6 +296,7 @@
window.location.replace("/yolo/yolovXSettings?IDX=<%= IDX %>");
}).catch((error) => {
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
console.log(error);
});
}
@@ -309,6 +312,8 @@
formData.append("PName", "<%= PName %>");
formData.append("Admin", "<%= Admin %>");
+ const submitBtn = event.target.querySelector('button[type="submit"]');
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Adding path...");
fetch(event.target.action, {
method: 'POST',
@@ -322,6 +327,7 @@
window.location.replace("/yolo/yolovXSettings?IDX=<%= IDX %>");
}).catch((error) => {
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
console.log(error);
});
});
@@ -329,12 +335,12 @@
// Class selection helper buttons
var selectAllBtn = document.getElementById("selectAllClasses");
var deselectAllBtn = document.getElementById("deselectAllClasses");
- var minClassImagesInput = document.getElementById("minClassImages");
+ var minClassLabelsInput = document.getElementById("minClassLabels");
- // Unchecks any class checkbox whose image count is below the configured minimum,
+ // Unchecks any class checkbox whose label count is below the configured minimum,
// so Select All (or manually re-checking a box) can't bypass the clamp.
- function applyMinClassImagesClamp() {
- var threshold = parseInt(minClassImagesInput && minClassImagesInput.value, 10) || 0;
+ function applyMinClassLabelsClamp() {
+ var threshold = parseInt(minClassLabelsInput && minClassLabelsInput.value, 10) || 0;
if (threshold <= 0) {
return;
}
@@ -350,7 +356,7 @@
if (selectAllBtn) {
selectAllBtn.addEventListener("click", function() {
document.querySelectorAll(".class-checkbox").forEach(function(cb) { cb.checked = true; });
- applyMinClassImagesClamp();
+ applyMinClassLabelsClamp();
});
}
if (deselectAllBtn) {
@@ -358,8 +364,8 @@
document.querySelectorAll(".class-checkbox").forEach(function(cb) { cb.checked = false; });
});
}
- if (minClassImagesInput) {
- minClassImagesInput.addEventListener("input", applyMinClassImagesClamp);
+ if (minClassLabelsInput) {
+ minClassLabelsInput.addEventListener("input", applyMinClassLabelsClamp);
}
// Split ratio summary update
@@ -419,6 +425,9 @@
formData.append("imgsz", document.getElementById("imgsz").value);
formData.append("device", document.getElementById("device").value);
+ const submitBtn = document.getElementById("Configbtn");
+ if (window.LoadingSpinner) LoadingSpinner.show(submitBtn, "Starting training...");
+
fetch(event.target.action, {
method: 'POST',
body: formData
@@ -431,6 +440,7 @@
window.location.replace("/training?IDX=<%= IDX %>");
}).catch((error) => {
+ if (window.LoadingSpinner) LoadingSpinner.hide(submitBtn);
console.log(error);
});
});