Image-to-Text
English
custom
ocr
document-ai
information-extraction
nameplate
nameplate-ocr
equipment-nameplate
data-center
data-centre
datacenter
mission-critical
mep
electrical
equipment-schedule
schedule-verification
asset-register
commissioning
quality-assurance
ups
pdu
switchgear
generator
ats
transformer
busway
battery
crah
crac
chiller
cooling
construction
aec
edge-ai
on-device
privacy-preserving
tesseract
browser
File size: 10,555 Bytes
5e2d1eb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | // Schedule import for Progress mode: planned dates from the project schedule
// instead of typed in area by area β the link Buildots makes between what the
// camera saw and the programme (P6 / MS Project). Reads a CSV export (P6,
// MS Project, Smartsheet and Excel all export one) or an MS Project XML file,
// then matches each activity to the app's areas and trades by name:
//
// "L3 Unit 3B β hang drywall" β area "L3 Unit 3B", trade Walls
// "Level 4 ceiling grid" β every area on floor L4, trade Ceiling
// "L3 Unit 3B fit-out" β area "L3 Unit 3B", the area's own window
//
// Nothing is applied until the user has seen what matched and what did not.
// Pure functions (tests/schedule-import.test.mjs).
import { floorOf } from './progress-project.js';
const MONTHS = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };
/**
* Schedule dates as local noon (the app stores capture dates the same way).
* ISO 2026-09-01 (optionally with a time), US 9/1/2026 or 09/01/26, P6 01-Sep-26 / 01-Sep-2026, "Sep 1, 2026".
* A trailing "A" (P6 actual) or "*" (constraint) is ignored.
*/
export function parseScheduleDate(raw) {
const s = String(raw ?? '').trim().replace(/\s*[A*]$/, '');
if (!s) return null;
let m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (m) return new Date(+m[1], +m[2] - 1, +m[3], 12).getTime();
m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
if (m) return new Date(+m[3] < 100 ? 2000 + +m[3] : +m[3], +m[1] - 1, +m[2], 12).getTime();
m = s.match(/^(\d{1,2})[- ]([A-Za-z]{3})[a-z]*[- ,]+(\d{2,4})/);
if (m && MONTHS[m[2].toLowerCase()] != null) return new Date(+m[3] < 100 ? 2000 + +m[3] : +m[3], MONTHS[m[2].toLowerCase()], +m[1], 12).getTime();
m = s.match(/^([A-Za-z]{3})[a-z]*\.? (\d{1,2}),? (\d{4})/);
if (m && MONTHS[m[1].toLowerCase()] != null) return new Date(+m[3], MONTHS[m[1].toLowerCase()], +m[2], 12).getTime();
return null;
}
/** RFC-4180-ish CSV: quoted fields, doubled quotes, commas/newlines inside quotes; also tab-separated. */
export function parseCsv(text) {
const delim = (text.split('\n')[0].match(/\t/g) || []).length > (text.split('\n')[0].match(/,/g) || []).length ? '\t' : ',';
const rows = [];
let row = [], field = '', q = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q) {
if (ch === '"' && text[i + 1] === '"') { field += '"'; i++; }
else if (ch === '"') q = false;
else field += ch;
} else if (ch === '"') q = true;
else if (ch === delim) { row.push(field); field = ''; }
else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(field); field = '';
if (row.some(c => c.trim())) rows.push(row);
row = [];
} else field += ch;
}
row.push(field);
if (row.some(c => c.trim())) rows.push(row);
return rows;
}
const HEADERS = {
name: /^(activity|task|activity name|task name|name|description|activity description)$/i,
start: /^(start|start date|planned start|early start|baseline start|bl start)$/i,
finish: /^(finish|finish date|end|end date|planned finish|early finish|baseline finish|bl finish)$/i,
area: /^(area|location|zone|room|space|wbs name)$/i,
trade: /^(trade|discipline|activity code|responsibility)$/i,
};
/** Activities from a CSV export: finds the header row (first row naming a start and a finish column). */
export function parseScheduleCsv(text) {
const rows = parseCsv(text);
const headerIdx = rows.findIndex(r => r.some(c => HEADERS.start.test(c.trim())) && r.some(c => HEADERS.finish.test(c.trim())));
if (headerIdx < 0) return { activities: [], problems: ['No header row with a start and a finish column was found.'] };
const header = rows[headerIdx].map(c => c.trim());
const col = key => header.findIndex(h => HEADERS[key].test(h));
const cols = { name: col('name'), start: col('start'), finish: col('finish'), area: col('area'), trade: col('trade') };
if (cols.name < 0) cols.name = header.findIndex((h, i) => i !== cols.start && i !== cols.finish && h);
const activities = [], problems = [];
rows.slice(headerIdx + 1).forEach((r, i) => {
const name = (r[cols.name] || '').trim();
const start = parseScheduleDate(r[cols.start]), finish = parseScheduleDate(r[cols.finish]);
if (!name) return;
if (start == null || finish == null) { problems.push(`Row ${headerIdx + i + 2} (${name}): unreadable dates`); return; }
activities.push({ name, start: Math.min(start, finish), finish: Math.max(start, finish),
area: cols.area >= 0 ? (r[cols.area] || '').trim() || null : null, trade: cols.trade >= 0 ? (r[cols.trade] || '').trim() || null : null });
});
return { activities, problems };
}
const xmlText = s => s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
/** Activities from an MS Project XML export (File β Save As β XML). Summary tasks are skipped. */
export function parseMsProjectXml(text) {
const activities = [], problems = [];
for (const block of text.match(/<Task>[\s\S]*?<\/Task>/g) || []) {
const get = tag => { const m = block.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`)); return m ? xmlText(m[1]).trim() : null; };
if (get('Summary') === '1' || get('IsNull') === '1') continue;
const name = get('Name');
if (!name) continue;
const start = parseScheduleDate(get('Start')), finish = parseScheduleDate(get('Finish'));
if (start == null || finish == null) { problems.push(`${name}: unreadable dates`); continue; }
activities.push({ name, start, finish, area: null, trade: null });
}
return { activities, problems };
}
export function parseSchedule(text, fileName = '') {
return /\.xml$/i.test(fileName) || /^\s*<\?xml|<Project[\s>]/.test(text) ? parseMsProjectXml(text) : parseScheduleCsv(text);
}
// Trade keywords β Progress mode's item keys. Order matters: "ceiling grid" is Ceiling, not Walls.
const TRADES = [
['ceiling', /\b(ceiling|ceilings|act|acoustic|grid|soffit|bulkhead)\b/i],
['exits', /\bexit\b/i],
['supply', /\b(diffuser|diffusers|supply air)\b/i],
['returns', /\b(return|returns|grille|grilles)\b/i],
['fixtures', /\b(light|lights|lighting|fixture|fixtures|luminaire|luminaires)\b/i],
['wall', /\b(drywall|gypsum|gyp|plasterboard|sheetrock|board|partition|partitions|stud|studs|framing|frame|tape|taping|mud|finish walls|wall|walls|paint|painting)\b/i],
];
export function tradeOf(text) {
for (const [key, re] of TRADES) if (re.test(text || '')) return key;
return null;
}
const norm = s => String(s || '').toLowerCase().replace(/[Β·β’|_]+/g, ' ').replace(/[-ββ]+/g, ' ').replace(/\s+/g, ' ').trim();
/** A floor named in an activity ("Level 4 β¦", "L4 β¦", "4th floor β¦") in the same keys floorOf gives areas. */
function floorInText(text) {
const s = String(text || '');
let m = s.match(/\b(?:l|lvl|level|fl|floor)\s*-?\s*0*(\d+)\b/i) || s.match(/\b0*(\d+)(?:st|nd|rd|th)?\s*(?:f|fl|floor)\b/i);
if (m) return `L${Number(m[1])}`;
m = s.match(/\b(?:b|basement)\s*-?\s*0*(\d+)\b/i);
return m ? `B${Number(m[1])}` : null;
}
/**
* Match activities to areas and trades. An activity names an area when the
* area's name appears in it (longest name wins, so "L3 Unit 3B" beats "L3");
* otherwise, if it names a floor, it applies to every area on that floor that
* has no activity of its own for that trade. Returns per-area windows and
* per-trade windows (earliest start β latest finish across matching activities)
* plus what could not be placed.
*/
export function matchSchedule(activities, areaNames) {
const areas = [...areaNames].sort((a, b) => norm(b).length - norm(a).length);
const plan = {}, unmatched = [], used = [];
const widen = (w, a) => ({ start: Math.min(w?.start ?? Infinity, a.start), finish: Math.max(w?.finish ?? -Infinity, a.finish) });
const direct = new Set(); // "area|trade" set by an activity naming the area itself
const floorActs = [];
for (const act of activities) {
const hay = norm(`${act.area || ''} ${act.name}`);
const area = areas.find(n => norm(n) && (norm(act.area) === norm(n) || hay.includes(norm(n))));
const trade = tradeOf(act.trade) || tradeOf(act.name);
if (area) {
const p = (plan[area] ||= { window: null, trades: {}, activities: [] });
if (trade) { p.trades[trade] = widen(p.trades[trade], act); direct.add(`${area}|${trade}`); }
else p.window = widen(p.window, act);
p.activities.push(act.name);
used.push(act);
} else if (floorInText(act.area || act.name)) floorActs.push({ act, floor: floorInText(act.area || act.name), trade });
else unmatched.push(act.name);
}
for (const { act, floor, trade } of floorActs) {
const onFloor = areaNames.filter(n => floorOf(n) === floor);
if (!onFloor.length) { unmatched.push(act.name); continue; }
for (const area of onFloor) {
const p = (plan[area] ||= { window: null, trades: {}, activities: [] });
if (trade) { if (direct.has(`${area}|${trade}`)) continue; p.trades[trade] = widen(p.trades[trade], act); }
else if (!p.window) p.window = { start: act.start, finish: act.finish };
p.activities.push(`${act.name} (floor ${floor})`);
}
used.push(act);
}
// An area whose schedule only names trades gets a window spanning them β
// marked as derived, so applying it never replaces dates the area already has.
for (const p of Object.values(plan)) {
if (!p.window && Object.keys(p.trades).length) {
p.window = Object.values(p.trades).reduce((w, t) => ({ start: Math.min(w.start, t.start), finish: Math.max(w.finish, t.finish) }), { start: Infinity, finish: -Infinity });
p.derivedWindow = true;
}
}
return { plan, unmatched, matched: used.length };
}
/** Write a match into a Progress store (mutates and returns it). Imported dates replace typed ones for matched areas only. */
export function applySchedule(store, plan) {
for (const [area, p] of Object.entries(plan)) {
const rec = (store.areas[area] ||= { captures: [] });
const hasOwn = Number.isFinite(rec.schedule?.start) && Number.isFinite(rec.schedule?.finish);
const setWindow = p.window && !(p.derivedWindow && hasOwn);
rec.schedule = { ...(rec.schedule || {}), ...(setWindow ? { start: p.window.start, finish: p.window.finish } : {}),
trades: { ...(rec.schedule?.trades || {}), ...p.trades } };
}
return store;
}
|