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
Download schedule-import.js from constructelligence/data-centre-nameplates: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/constructelligence/data-centre-nameplates/resolve/main/schedule-import.js
- Command line
-
hf download hf://constructelligence/data-centre-nameplates/schedule-import.js
-
curl -L -o schedule-import.js https://huggingface.co/constructelligence/data-centre-nameplates/resolve/main/schedule-import.js
10.6 kB
| // 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; | |
| } | |