mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Артефакт один — scripts/sh3d-convert/dist/index.html, самодостаточный файл, как и лендинг: раскладывается копированием, без сборщиков и CDN. Собирается склейкой тех же модулей, которые проверяют гейты этапа 1; относительных импортов на странице нет, поэтому она работает из любого каталога и без сети. Три обещания страницы проверяются тестами, а не текстом: 1. Ни одного сетевого вызова и ни одной внешней загрузки. Ищутся формы ВЫЗОВА, а не имена API: первая редакция теста краснела на собственном комментарии, где эти API перечислены как запрещённые. Шрифты системные — <link> к шрифтам это внешний запрос при загрузке, а обещание «файл не покидает браузер» должно быть буквальным. Заодно поэтому на странице нет аналитики. 2. Конвертер внутри страницы даёт тот же результат, что модули репозитория: код извлекается из собранного артефакта, прогоняется на фикстуре и сравнивается с golden. Склейка, потерявшая модуль, краснеет. 3. Собранная страница не отстала от исходников: build-page.mjs --check сверяет закоммиченный артефакт с пересборкой. Плюс полнота переводов: у каждой подписи есть ru и en, словари совпадают по ключам, у каждого кода отчёта и каждого кода отказа есть человеческий текст. Иначе пользователь увидит пустую надпись или `not_zip`. Отчёт показывается ДО скачивания и по этажам: сколько комнат, стен и проёмов, какая клетка сетки, и полный список мест, где конвертер принял решение, — спрямлённая дуга, обрезанная толщина, непривязанный проём, пропущенный этаж без комнат. Это требование задачи: искать чужие ошибки в готовом плане из сотен объектов хуже, чем прочитать их заранее. Гайды (ru/en) получили раздел про импорт из Sweet Home 3D с прямой записью, что это необязательный ярлык, а не шаг настройки: SCOPE обещает «no external editors», и обходить это молчанием нельзя. Свидетели этапа 2, все проверены отрицательным прогоном: добавленный в страницу fetch, потерянный русский перевод, правка dist руками, склейка без модуля sh3d.mjs — каждая краснит свой тест. Гейты: npm test 1871 tests, 1870 pass, 0 fail; pytest без HA 378 passed, 3 skipped. Осталось владельцу: раскладка на хост (из песочницы SSH недоступен) и проверка на настоящем файле Sweet Home 3D. Issue: #446 User-Visible: no
289 lines
15 KiB
JavaScript
289 lines
15 KiB
JavaScript
/**
|
||
* Страница /convert: интерфейс конвертера (#446).
|
||
*
|
||
* Вся конверсия идёт здесь, в браузере. Ни одного сетевого вызова на этой
|
||
* странице нет и быть не должно: план дома — приватные данные, а сама страница
|
||
* живёт на хосте, который по нашему описанию расходный и потенциально
|
||
* враждебный. Отсутствие `fetch`/`XMLHttpRequest`/`WebSocket`/`sendBeacon`
|
||
* проверяется тестом по собранному артефакту, а не обещанием в тексте.
|
||
*
|
||
* Отчёт показывается ДО скачивания — это требование задачи, а не украшение:
|
||
* человек должен узнать про спрямлённую дугу и непривязанную дверь заранее, а
|
||
* не искать их потом в плане из сотен объектов.
|
||
*/
|
||
import { readSh3d } from '../sh3d.mjs';
|
||
import { ConvertError, MIN_HOUSEPLAN, convertHome } from '../convert.mjs';
|
||
|
||
const TOOL_VERSION = 'sh3d-convert 0.1';
|
||
const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
||
|
||
/** Словари вынесены в экспорт: полноту ключей проверяет тест страницы. */
|
||
export const TEXT = {
|
||
ru: {
|
||
'page.title': 'Конвертер Sweet Home 3D → House Plan',
|
||
'page.lead': 'Файл плана из Sweet Home 3D превращается в документ импорта House Plan — по одному на этаж.',
|
||
'page.privacy': 'Конверсия идёт в вашем браузере. Файл никуда не загружается: страница не делает ни одного сетевого запроса.',
|
||
'page.requires': `Требуется House Plan ${MIN_HOUSEPLAN} или новее.`,
|
||
'drop.title': 'Перетащите сюда файл .sh3d',
|
||
'drop.or': 'или',
|
||
'drop.button': 'Выбрать файл',
|
||
'drop.hint': 'Ничего не уходит с вашего компьютера',
|
||
'busy': 'Читаю файл…',
|
||
'result.title': 'Что получилось',
|
||
'result.download': 'Скачать этаж',
|
||
'result.rooms': 'комнат',
|
||
'result.walls': 'стен',
|
||
'result.openings': 'проёмов',
|
||
'result.cell': 'клетка сетки',
|
||
'result.skipped': 'этаж пропущен: комнат нет',
|
||
'result.notes': 'На что посмотреть после импорта',
|
||
'howto.title': 'Как импортировать',
|
||
'howto.1': 'Откройте карточку House Plan → Общие настройки → Резервная копия и перенос.',
|
||
'howto.2': 'Выберите импорт и укажите скачанный файл: House Plan покажет предпросмотр и ничего не запишет до подтверждения.',
|
||
'howto.3': 'Этаж добавится рядом с существующими пространствами. Привязку комнат к областям Home Assistant и расстановку устройств сделайте в редакторе — в файле их нет.',
|
||
'error.title': 'Не получилось',
|
||
'error.retry': 'Выбрать другой файл',
|
||
'back': 'На главную',
|
||
'note.vertices_snapped': 'Вершины комнат выровнены по осевым линиям стен: {count}. Sweet Home 3D обводит комнаты по внутренним граням, иначе общая стена превратилась бы в две.',
|
||
'note.curved_wall_straightened': 'Круглых стен спрямлено: {count}. Дуг в House Plan нет.',
|
||
'note.thickness_clamped': 'Стен толще {limit} см обрезано до предела: {count}.',
|
||
'note.edge_without_wall': 'Границ без стены: {count}. Такая граница останется открытой — стены в файле там не было.',
|
||
'note.opening_unhosted': 'Проём не привязался ни к одной стене и не перенесён: {id}.',
|
||
'note.opening_shortened': 'Проём шире стены и обрезан до неё: {id}.',
|
||
'note.opening_without_width': 'Проём без ширины пропущен: {id}.',
|
||
'note.room_without_polygon': 'Комната без контура пропущена: {id}.',
|
||
'note.room_collapsed': 'Контур комнаты рассыпался после выравнивания и пропущен: {id}.',
|
||
'note.level_without_rooms': 'Этаж «{title}» пропущен: в нём нет ни одной комнаты. House Plan строит геометрию по комнатам — нарисуйте их в Sweet Home 3D.',
|
||
'note.furniture_dropped': 'Мебель, материалы, свет и камеры не переносятся: House Plan — живая карта дома, а не редактор интерьера.',
|
||
'code.not_zip': 'Это не файл Sweet Home 3D: архив не читается.',
|
||
'code.entry_missing': 'В архиве нет Home.xml. Пересохраните план в Sweet Home 3D 5.3 или новее.',
|
||
'code.encrypted': 'Архив защищён паролем.',
|
||
'code.zip64': 'Такой архив (zip64) не поддерживается.',
|
||
'code.entry_too_large': 'Файл слишком большой.',
|
||
'code.entity_declaration': 'В файле объявлены XML-сущности — такой файл не читается.',
|
||
'code.entity_reference': 'В файле есть неизвестная XML-сущность.',
|
||
'code.nothing_to_convert': 'В плане нет ни одной комнаты. House Plan строит геометрию по комнатам: нарисуйте их в Sweet Home 3D и сохраните файл заново.',
|
||
'code.room_too_complex': 'Слишком сложный контур комнаты.',
|
||
'code.unit_not_metric': 'План сохранён не в метрических единицах.',
|
||
'code.file_too_large': 'Файл больше 64 МБ.',
|
||
'code.unknown': 'Неизвестная ошибка чтения файла.',
|
||
},
|
||
en: {
|
||
'page.title': 'Sweet Home 3D → House Plan converter',
|
||
'page.lead': 'Turns a Sweet Home 3D file into House Plan import documents — one per level.',
|
||
'page.privacy': 'The conversion runs in your browser. The file is never uploaded: this page makes no network requests at all.',
|
||
'page.requires': `Requires House Plan ${MIN_HOUSEPLAN} or newer.`,
|
||
'drop.title': 'Drop a .sh3d file here',
|
||
'drop.or': 'or',
|
||
'drop.button': 'Choose a file',
|
||
'drop.hint': 'Nothing leaves your computer',
|
||
'busy': 'Reading the file…',
|
||
'result.title': 'What came out',
|
||
'result.download': 'Download level',
|
||
'result.rooms': 'rooms',
|
||
'result.walls': 'walls',
|
||
'result.openings': 'openings',
|
||
'result.cell': 'grid cell',
|
||
'result.skipped': 'level skipped: no rooms',
|
||
'result.notes': 'What to check after the import',
|
||
'howto.title': 'How to import',
|
||
'howto.1': 'Open the House Plan card → General settings → Backup & transfer.',
|
||
'howto.2': 'Pick import and choose the downloaded file: House Plan shows a preview and writes nothing until you confirm.',
|
||
'howto.3': 'The level is added next to your existing spaces. Bind rooms to Home Assistant areas and place devices in the editor — the file carries neither.',
|
||
'error.title': 'That did not work',
|
||
'error.retry': 'Choose another file',
|
||
'back': 'Back to the site',
|
||
'note.vertices_snapped': 'Room vertices aligned to wall centrelines: {count}. Sweet Home 3D traces rooms along inner faces, so without this a shared wall would become two.',
|
||
'note.curved_wall_straightened': 'Curved walls straightened: {count}. House Plan has no arcs.',
|
||
'note.thickness_clamped': 'Walls thicker than {limit} cm clamped to the limit: {count}.',
|
||
'note.edge_without_wall': 'Boundaries without a wall: {count}. They stay open — there was no wall in the file.',
|
||
'note.opening_unhosted': 'This opening matched no wall and was skipped: {id}.',
|
||
'note.opening_shortened': 'Opening wider than its wall, shortened to fit: {id}.',
|
||
'note.opening_without_width': 'Opening without a width, skipped: {id}.',
|
||
'note.room_without_polygon': 'Room without an outline, skipped: {id}.',
|
||
'note.room_collapsed': 'Room outline collapsed after alignment and was skipped: {id}.',
|
||
'note.level_without_rooms': 'Level “{title}” skipped: it has no rooms. House Plan builds geometry from rooms — draw them in Sweet Home 3D.',
|
||
'note.furniture_dropped': 'Furniture, materials, lights and cameras are not carried over: House Plan is a live map of the home, not an interior editor.',
|
||
'code.not_zip': 'This is not a Sweet Home 3D file: the archive cannot be read.',
|
||
'code.entry_missing': 'The archive has no Home.xml. Re-save the plan in Sweet Home 3D 5.3 or newer.',
|
||
'code.encrypted': 'The archive is password protected.',
|
||
'code.zip64': 'This archive (zip64) is not supported.',
|
||
'code.entry_too_large': 'The file is too large.',
|
||
'code.entity_declaration': 'The file declares XML entities and cannot be read.',
|
||
'code.entity_reference': 'The file contains an unknown XML entity.',
|
||
'code.nothing_to_convert': 'The plan has no rooms. House Plan builds geometry from rooms: draw them in Sweet Home 3D and save the file again.',
|
||
'code.room_too_complex': 'A room outline is too complex.',
|
||
'code.unit_not_metric': 'The plan is not saved in metric units.',
|
||
'code.file_too_large': 'The file is larger than 64 MB.',
|
||
'code.unknown': 'Unknown error while reading the file.',
|
||
},
|
||
};
|
||
|
||
let lang = 'en';
|
||
|
||
const fill = (template, values) =>
|
||
String(template).replace(/\{(\w+)\}/g, (whole, key) =>
|
||
(values && values[key] !== undefined ? String(values[key]) : whole));
|
||
|
||
const t = (key, values) => fill(TEXT[lang][key] ?? TEXT.en[key] ?? key, values);
|
||
|
||
function applyLanguage(next) {
|
||
lang = TEXT[next] ? next : 'en';
|
||
document.documentElement.lang = lang;
|
||
try {
|
||
localStorage.setItem('hp-convert-lang', lang);
|
||
} catch {
|
||
// Приватный режим: язык просто не запомнится, это не ошибка.
|
||
}
|
||
for (const node of document.querySelectorAll('[data-i18n]')) {
|
||
node.textContent = t(node.dataset.i18n);
|
||
}
|
||
for (const node of document.querySelectorAll('[data-lang]')) {
|
||
node.classList.toggle('on', node.dataset.lang === lang);
|
||
}
|
||
if (lastResult) renderResult(lastResult);
|
||
}
|
||
|
||
let lastResult = null;
|
||
|
||
const element = (tag, className, text) => {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
};
|
||
|
||
const show = (id, visible) => {
|
||
document.getElementById(id).hidden = !visible;
|
||
};
|
||
|
||
function noteText(note) {
|
||
const key = `note.${note.code}`;
|
||
const known = TEXT[lang][key] || TEXT.en[key];
|
||
return known ? fill(known, note) : `${note.code}: ${JSON.stringify(note)}`;
|
||
}
|
||
|
||
function download(name, document_) {
|
||
const blob = new Blob([`${JSON.stringify(document_, null, 2)}\n`], {
|
||
type: 'application/json',
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const link = element('a');
|
||
link.href = url;
|
||
link.download = name;
|
||
link.click();
|
||
// Ссылка живёт ровно до клика: держать её незачем, а память жалко.
|
||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||
}
|
||
|
||
function renderResult(result) {
|
||
lastResult = result;
|
||
const { documents, report, stem } = result;
|
||
const host = document.getElementById('levels');
|
||
host.textContent = '';
|
||
let downloadIndex = 0;
|
||
for (const level of report.levels) {
|
||
const card = element('div', 'level');
|
||
card.append(element('h3', null, level.title || level.id || '—'));
|
||
if (level.skipped) {
|
||
card.append(element('p', 'muted', t('result.skipped')));
|
||
} else {
|
||
const stats = element('p', 'stats');
|
||
stats.textContent = [
|
||
`${level.rooms} ${t('result.rooms')}`,
|
||
`${level.walls} ${t('result.walls')}`,
|
||
`${level.openings} ${t('result.openings')}`,
|
||
`${t('result.cell')}: ${level.cellCm} cm`,
|
||
].join(' · ');
|
||
card.append(stats);
|
||
const index = downloadIndex++;
|
||
const button = element('button', 'btn primary', t('result.download'));
|
||
button.addEventListener('click', () =>
|
||
download(`${stem}.${level.spaceId}.json`, documents[index]));
|
||
card.append(button);
|
||
}
|
||
const notes = [...(level.notes || [])];
|
||
if (notes.length) {
|
||
card.append(element('h4', 'notes-title', t('result.notes')));
|
||
const list = element('ul', 'notes');
|
||
for (const note of notes) list.append(element('li', null, noteText(note)));
|
||
card.append(list);
|
||
}
|
||
host.append(card);
|
||
}
|
||
const shared = element('ul', 'notes');
|
||
for (const item of report.items) shared.append(element('li', null, noteText(item)));
|
||
document.getElementById('shared-notes').replaceChildren(shared);
|
||
show('result', true);
|
||
show('error', false);
|
||
show('busy', false);
|
||
}
|
||
|
||
function renderError(code, detail) {
|
||
const key = `code.${code}`;
|
||
const known = TEXT[lang][key] || TEXT.en[key];
|
||
document.getElementById('error-text').textContent = known || t('code.unknown');
|
||
const details = document.getElementById('error-detail');
|
||
details.textContent = detail && !known ? detail : '';
|
||
details.hidden = !details.textContent;
|
||
show('error', true);
|
||
show('result', false);
|
||
show('busy', false);
|
||
}
|
||
|
||
async function handleFile(file) {
|
||
if (!file) return;
|
||
lastResult = null;
|
||
show('busy', true);
|
||
show('error', false);
|
||
show('result', false);
|
||
try {
|
||
if (file.size > MAX_FILE_BYTES) {
|
||
renderError('file_too_large');
|
||
return;
|
||
}
|
||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||
const home = await readSh3d(bytes);
|
||
const { documents, report } = convertHome(home, { toolVersion: TOOL_VERSION });
|
||
renderResult({
|
||
documents,
|
||
report,
|
||
stem: file.name.replace(/\.sh3d$/i, '') || 'plan',
|
||
});
|
||
} catch (error) {
|
||
const code = error && error.code ? error.code : 'unknown';
|
||
renderError(code, error instanceof ConvertError ? error.message : String(error?.message || ''));
|
||
}
|
||
}
|
||
|
||
export function mount() {
|
||
const input = document.getElementById('file');
|
||
const zone = document.getElementById('drop');
|
||
input.addEventListener('change', () => handleFile(input.files[0]));
|
||
document.getElementById('pick').addEventListener('click', () => input.click());
|
||
document.getElementById('retry').addEventListener('click', () => input.click());
|
||
for (const event of ['dragenter', 'dragover']) {
|
||
zone.addEventListener(event, (raw) => {
|
||
raw.preventDefault();
|
||
zone.classList.add('over');
|
||
});
|
||
}
|
||
for (const event of ['dragleave', 'drop']) {
|
||
zone.addEventListener(event, () => zone.classList.remove('over'));
|
||
}
|
||
zone.addEventListener('drop', (raw) => {
|
||
raw.preventDefault();
|
||
handleFile(raw.dataTransfer?.files?.[0]);
|
||
});
|
||
for (const node of document.querySelectorAll('[data-lang]')) {
|
||
node.addEventListener('click', () => applyLanguage(node.dataset.lang));
|
||
}
|
||
let saved = null;
|
||
try {
|
||
saved = localStorage.getItem('hp-convert-lang');
|
||
} catch {
|
||
saved = null;
|
||
}
|
||
applyLanguage(saved || (String(navigator.language || '').toLowerCase().startsWith('ru') ? 'ru' : 'en'));
|
||
}
|