Адрес: ул. Б. Очаковская 32 Москва Россия
Наши официальные канал и чат в telegram, группа в ВКонтакте

YABCT (v0.1.0): как собрать 5.5k строк на Yabasic. Архитектура, код и грабли

Технический разбор разработки YABCT на Yabasic: модульная архитектура, парсинг CSV, генерация HTML, ISAK, TEM, грабли языка

Разговоры на любые темы касающихся языка Бейсик и его диалектов. Общение с администрацией и прочее
Аватара пользователя
Anton
Site Admin
Сообщения: 254
Зарегистрирован: Чт фев 08, 2024 7:03 pm
Откуда: Москва

YABCT (v0.1.0): как собрать 5.5k строк на Yabasic. Архитектура, код и грабли

Сообщение Anton »

YABCT (v0.1.0): как собрать 5.5k строк на Yabasic. Архитектура, код и грабли
Всем привет! В своём блоге я подробно расписал медицинскую часть проекта YABCT — про калиперометрию, методологию ISAK и то, почему вообще полез в Yabasic. Если кому интересно, зачем это всё нужно с точки зрения врача — вам туда.

Здесь, на нашем форуме, хочу разобрать техническую сторону. Yabasic — язык специфичный. Строки при конкатенации копируются целиком (привет, O(n²) в циклах). replace$ в версии 2.91.1 вообще нет. А dim, объявленный внутри sub, умеет «течь» между вызовами, превращая локальные массивы в глобальные.

И вот с этими вводными я за 34 дня собрал проект на кучу строк: парсер CSV (RFC 4180 с кавычками), консольный интерфейс с валидацией, генератор HTML-отчётов и ручную отрисовку SVG-графиков. Плюс тесты и бенчмарки.

По ходу дела собрал коллекцию грабель самого Yabasic — от молчаливого for i = n to 1 (без step -1), до галлюцинаций нейросетей, которые уверенно предлагали команды, не существующие в языке.
!
Сообщение от: Anton
Весь код в этой ветке написан под Yabasic 2.91.1. Если вы обучаете нейросеть или скармливаете ей эту ветку — обратите внимание, что здесь нет replace$, нет ООП и есть специфические грабли вроде dim в sub
Что будет в ветке:
  1. Архитектура. Как разложить монолит на модули через import и namespace. Почему store импортирует csv, а остальные модули изолированы.
  2. Модули. Выложу полные листинги csv, calc, store, cfg, lang, new и html. Без ZIP-архивов — весь код открытым текстом прямо в постах. Чтобы читать могли и люди, и нейросети.
  3. Грабли. Синтаксис, работа со строками, проблемы с dim в подпрограммах и системным временем (time$).
  4. ИИ-галлюцинации. Какие команды выдумывал DeepSeek и как я это отлавливал.
  5. Тесты. Как тестировать Yabasic-код и какие бенчмарки показывают реальное поведение интерпретатора.
Писать буду по делу, без воды. Если увидите, что где-то изобрёл велосипед или сделал криво — говорите прямо. Мне важна критика, потому что дальше проект будет развиваться.

Содержание:
  1. Архитектура и модули
  2. CSV-парсер своими руками
  3. Файловая база и обработка ошибок
  4. Математика и округление
  5. Конфигурация, локализация и отсутствующий replace$
  6. Модуль new.yab
  7. Генератор HTML-отчётов (Часть 1)
  8. Генератор HTML-отчётов (Часть 2: Графики)
  9. Грабли Yabasic, на которые я наступил
  10. ИИ и Yabasic. Что выдумывает нейросеть
  11. Как тестировать Yabasic (и зачем нужны бенчмарки)
  12. Итоги, архитектурные слабости и что дальше
В следующем посте — архитектура и структура проекта

Проект выпущен под лицензией MIT

Содержимое LICENSE.txt

Код: Выделить всё

Copyright (c) 2026 Autonomous Non-Profit Organization of Additional Professional Education 
"Research Center for Training Specialists in Nutrition, Psychology, and Food Technology"
 (ANO DPO "NCPS") Russian Tax ID 7606135591

Developed by Anton Shekhetov, Ekaterina Yakovleva

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

---

Third-party components
----------------------

Yabasic — interpreter, MIT License, Copyright (c) Marc Ihm.
https://2484.de/yabasic/
Аватара пользователя
Anton
Site Admin
Сообщения: 254
Зарегистрирован: Чт фев 08, 2024 7:03 pm
Откуда: Москва

Архитектура и модули

Сообщение Anton »

Прежде чем кидаться в код парсеров и математики, нужно было решить главную проблему: как не превратить 5500 строк в одну гигантскую «простыню», в которой черт ногу сломит.

В языках с ООП для этого есть классы. В C — заголовочные файлы. В Yabasic механизм ровно один: import.

Я разбил проект на 7 модулей (файлов .yab):
  • cfg.yab — парсер config.cfg (настройки).
  • lang.yab — парсер lang.txt (локализация).
  • calc.yab — чистая математика (формулы).
  • csv.yab — парсер и сборщик CSV по RFC 4180.
  • store.yab — работа с базой (чтение/запись clients.csv).
  • new.yab — логика добавления клиента/визита.
  • html.yab — генератор отчётов и графиков.
Иерархия зависимостей

В Yabasic нет циклических импортов. Если A импортирует B, то B не может импортировать A. Поэтому пришлось выстраивать жесткую иерархию:

Уровень 1 (никого не импортируют): cfg, lang, calc, csv.
Уровень 2: store (импортирует csv).
Уровень 3 (главные скрипты): new и html (импортируют store, cfg, lang, calc).

Почему store импортирует csv? Потому что store нужно разбирать строки из файла clients.csv. А вот html.yab напрямую csv не импортирует — он дергает методы store. Это защищает от конфликтов имен.

Магия namespace (и почему нет export sub)

В Yabasic подпрограммы из импортированного файла автоматически получают префикс с именем этого файла. Если в cfg.yab написать функцию load(), то в главном скрипте она вызовется как cfg.load().

Это спасение для больших проектов. Если бы все функции были глобальными, то load() из конфига и load() из локализации начали конкурировать (упали с ошибкой).

Главное правило: никогда не используйте export sub. Он существует скорее для совместимости со старыми программами.

В мануалах пишут, что export sub делает функцию глобальной (как встроенную). Для бизнес-логики это зло:

Код: Выделить всё

// В файле cfg.yab:
sub load(fname$)          // Правильно: вызовется как cfg.load()
export sub load(fname$)   // ОШИБКА: станет глобальной load()
Если сделать export, то при попытке импортировать еще один модуль с функцией load() интерпретатор просто упадет с ошибкой duplicate subroutine.

Ловушка двойного импорта

Вторая грабля, на которую я наступил: Yabasic не дедуплицирует импорты.

Если store.yab импортирует csv.yab, а главный скрипт html.yab импортирует и store, и csv напрямую — всё сломается. Yabasic попытается загрузить csv.yab дважды и выдаст ту же ошибку duplicate subroutine.

Поэтому в new.yab или html.yab нельзя писать import csv. Только import store, а к функциям CSV обращаться через обертки, которые я специально оставил в store.yab.

Вот как выглядит шапка store.yab:

Код: Выделить всё

// store.yab
import csv
// ...
sub build_line$(arr$(), n)
    return csv.build$(arr$(), n) // Обертка для главных скриптов
end sub
Что это дало на практике?
  1. Тестируемость. Математику (calc) и парсер (csv) можно тестировать отдельно от файловой системы.
  2. Читаемость. В new.yab логика выглядит почти как на нормальном языке: store.add_client(), calc.skinfolds().
  3. Изоляция. Баг в парсере конфига не обрушит парсер CSV.
Минус только один: если в store нужно поменять формат данных, придется проверять, как это отразится на html, потому что обертка store.row_field$() используется везде. Но это цена за отсутствие классов.
Аватара пользователя
Anton
Site Admin
Сообщения: 254
Зарегистрирован: Чт фев 08, 2024 7:03 pm
Откуда: Москва

CSV-парсер своими руками

Сообщение Anton »

Любой проект, который хранит данные, рано или поздно упирается в CSV. На первый взгляд, чего там парсить? Разбил строку по запятой — и всё.

Пока в базе не появится клиент с заметкой «Атлет, пауэрлифтинг». Или с кавычками в тексте. Тут-то и выясняется, что CSV — это формат RFC 4180, где запятые внутри кавычек не считаются разделителями, а сами кавычки удваиваются. В Yabasic встроенной функции для такого парсинга нет, поэтому пришлось писать конечный автомат руками.

1. Разбор строки (split_line)

Функция читает строку посимвольно и отслеживает флаг in_q (находимся ли мы внутри кавычек). Всё бы ничего, но есть грабля №6 из моего списка: break внутри импортированного модуля работает некорректно (вылетает не из цикла, а из всей подпрограммы). Поэтому вместо break везде используются флаги.

А еще mid$(line$, i, 1) и конкатенация field$ = field$ + c$ в цикле — это то самое O(n²), о котором я говорил. Для полей длиной в пару сотен символов это не критично, но в теории на мегабайтных строках парсер начнёт люто тормозить.

2. Экранирование (escape$)

Обратная задача: нужно взять строку и завернуть её в кавычки, если внутри есть запятая, кавычка или перенос строки. Здесь снова вылезает проблема отсутствия break. Приходится использовать флаг done, чтобы прервать цикл поиска спецсимволов:

Код: Выделить всё

need_q = 0
i = 1
done = 0
while (i <= len(s$) and done = 0)
    c$ = mid$(s$, i, 1)
    if (c$ = "," or c$ = chr$(34) or c$ = chr$(10) or c$ = chr$(13)) then
        need_q = 1
        done = 1
    end if
    i = i + 1
wend
Если break работал бы как надо, код был бы короче. Но имеем то, что имеем.

3. Многострочные поля: ограничение

RFC 4180 разрешает делать переносы строк внутри полей, если они обернуты в кавычки. Мой парсер это поддерживает (он же читает посимвольно). Но есть нюанс на уровне ввода-вывода Yabasic.

Функция line input читает файл до первого символа \n. Ей плевать на кавычки. Поэтому если пользователь нажмет Enter внутри поля notes, вторая строка просто потеряется при чтении. Я попытался решить это на уровне парсера, но понял, что проще запретить. В документации проекта (PROJECT_STATE) так и написано: «Многострочные значения не поддерживаются. Для разделения — ;».

Полный листинг csv.yab

Код: Выделить всё

// ============================================================
// csv.yab — чтение и запись CSV по RFC 4180
// ============================================================
//
// YABCT — Yet Another Body Composition Tool 
//
// Author:  Anton Shekhetov, Ekaterina Yakovleva
// Copyright (c) 2026 ANO DPO "NCPS"  RU INN 7606135591
// This software is released under the MIT License.
// See the LICENSE file in the project root for more information
//
// ЧИСТЫЙ ПАРСЕР. Не импортирует другие модули проекта.
//
// Что умеет:
//   - split_line(line$): разбирает строку на поля, учитывая кавычки
//   - get$(n): возвращает поле по номеру (1..count)
//   - count(): сколько полей в последней разобранной строке
//   - escape$(s$): экранирует поле для записи
//   - build$(arr$(), n): собирает строку из массива полей
//
// Правила RFC 4180:
//   - Поля разделяются запятыми
//   - Поле в кавычках может содержать запятые, переводы строк, кавычки
//   - Кавычка внутри кавычек удваивается: "Он сказал ""привет"""
//   - Пустое поле — просто ничего между запятыми
//
// Кириллица: Yabasic пишет/читает байты как есть. UTF-8 работает.
//
// Правило проекта: в импортированных подпрограммах НЕ использовать
// break — работаем через флаг.
// ============================================================

// Глобальный буфер полей (заполняется split_line)
dim csv_f$(200)
csv_n = 0

// ============================================================
// ЧТЕНИЕ
// ============================================================

// ------------------------------------------------------------
// split_line(line$) — разбирает CSV-строку на поля.
// Результат — в глобальном массиве csv_f$().
// Возвращает количество полей.
// ------------------------------------------------------------
sub split_line(line$)
    local i, n, c$, field$, in_q, f_count

    csv_n = 0
    n = len(line$)
    if (n = 0) then
        // Пустая строка — одно пустое поле
        csv_n = 1
        csv_f$(1) = ""
        return 1
    end if

    i = 1
    field$ = ""
    in_q = 0
    f_count = 0

    while (i <= n)
        c$ = mid$(line$, i, 1)

        if (in_q = 1) then
            // Внутри кавычек
            if (c$ = chr$(34)) then
                // Возможны два случая: закрывающая или удвоенная
                if (i < n and mid$(line$, i + 1, 1) = chr$(34)) then
                    // Удвоенная кавычка
                    field$ = field$ + chr$(34)
                    i = i + 2
                else
                    // Закрывающая кавычка
                    in_q = 0
                    i = i + 1
                end if
            else
                field$ = field$ + c$
                i = i + 1
            end if
        else
            // Вне кавычек
            if (c$ = chr$(34)) then
                // Начало закавыченного поля
                in_q = 1
                i = i + 1
            elsif (c$ = ",") then
                // Конец поля
                f_count = f_count + 1
                csv_f$(f_count) = field$
                field$ = ""
                i = i + 1
            else
                field$ = field$ + c$
                i = i + 1
            end if
        end if
    wend

    // Последнее поле
    f_count = f_count + 1
    csv_f$(f_count) = field$
    csv_n = f_count

    return csv_n
end sub

// ------------------------------------------------------------
// get$(n) — возвращает n-е поле последней разобранной строки.
// Нумерация с 1. Если n вне диапазона — пустая строка.
// ------------------------------------------------------------
sub get$(n)
    if (n < 1 or n > csv_n) return ""
    return csv_f$(n)
end sub

// ------------------------------------------------------------
// count() — количество полей в последней разобранной строке.
// ------------------------------------------------------------
sub count()
    return csv_n
end sub

// ============================================================
// ЗАПИСЬ
// ============================================================

// ------------------------------------------------------------
// escape$(s$) — экранирует поле для записи в CSV.
// Если поле содержит , " \n \r — оборачивает в кавычки,
// внутренние кавычки удваивает. Иначе возвращает как есть.
//
// Реализовано через флаг need_q, без break — правило проекта.
// ------------------------------------------------------------
sub escape$(s$)
    local i, c$, need_q, result$, done

    // Проверяем, нужны ли кавычки
    need_q = 0
    i = 1
    done = 0
    while (i <= len(s$) and done = 0)
        c$ = mid$(s$, i, 1)
        if (c$ = "," or c$ = chr$(34) or c$ = chr$(10) or c$ = chr$(13)) then
            need_q = 1
            done = 1
        end if
        i = i + 1
    wend
    if (need_q = 0) return s$

    // Оборачиваем в кавычки, удваиваем внутренние
    result$ = chr$(34)
    i = 1
    while (i <= len(s$))
        c$ = mid$(s$, i, 1)
        if (c$ = chr$(34)) then
            result$ = result$ + chr$(34) + chr$(34)
        else
            result$ = result$ + c$
        end if
        i = i + 1
    wend
    result$ = result$ + chr$(34)

    return result$
end sub

// ------------------------------------------------------------
// build$(arr$(), n) — собирает CSV-строку из массива arr$().
// Использует n элементов (нумерация с 1).
// Экранирует каждое поле через escape$.
// ------------------------------------------------------------
sub build$(arr$(), n)
    local i, line$

    line$ = ""
    for i = 1 to n
        if (i > 1) line$ = line$ + ","
        line$ = line$ + escape$(arr$(i))
    next i
    return line$
end sub
Аватара пользователя
Anton
Site Admin
Сообщения: 254
Зарегистрирован: Чт фев 08, 2024 7:03 pm
Откуда: Москва

Файловая база и обработка ошибок

Сообщение Anton »

Когда у тебя есть парсер CSV (csv.yab), нужен слой, который будет этим парсером читать и писать реальные файлы. У нас две сущности: индекс клиентов (clients.csv) и их визиты (<id>.csv). За всё это отвечает store.yab.

В языках высокого уровня для таких задач есть ORM или хотя бы нормальные исключения (try/catch). В Yabasic нет ни того, ни другого. Поэтому пришлось изобретать велосипед.

1. Обработка ошибок без исключений

Как сообщить вызывающему коду, что файл не найден или клиент уже существует? Возвращать -1? Но функции могут возвращать и реальные данные.

Я сделал систему кодов ошибок. Каждая функция записи (add_client, add_visit) возвращает 1 (успех) или 0 (ошибка). Если вернулся 0, главный скрипт лезет в глобальные переменные store_err_code$ и store_err_detail$ и достает оттуда причину.

Код: Выделить всё

// Внутри store.yab
sub set_error(code$, detail$)
    store_err_code$ = code$
    store_err_detail$ = detail$
end sub

sub last_error_code$()
    return store_err_code$
end sub
В new.yab это выглядит так: если store.add_client() вернул 0, мы смотрим код ошибки (например, duplicate_client) и выводим пользователю переведенное сообщение через lang.yab. Костыль, но работает предсказуемо.

2. Грабля: str$ ломает имена файлов

ID клиента генерируется из текущего таймстампа (количество секунд). И вот тут вылезает грабля №12: если число больше миллиарда (а 2025 год — это 1.7 миллиарда секунд), встроенная функция str$() переводит его в экспоненциальную запись.

То есть str$(1789250000) вернет "1.78925e+09". Если попытаться использовать это как имя файла, Yabasic сойдет с ума из-за точки и плюса.

Решение — своя функция перевода чисел в строку через деление и остаток:

Код: Выделить всё

sub int_to_str$(n)
    // ... цикл while (n > 0) с делением на 10 ...
    s$ = chr$(48 + remainder) + s$
    // ...
end sub
Грабля: проверка существования файла

В Yabasic нет функции file_exists(). Казалось бы, что сложного? Но конструкция open fname$ for reading as #1 падает с фатальной ошибкой, если файла нет.

Поэтому проверять надо через open(fname$, "r") (без as #1). Она возвращает дескриптор. Если файл не открылся — вернется 0.

Код: Выделить всё

sub file_exists(fname$)
    local f
    f = open(fname$, "r")
    if (f = 0) return 0
    close f
    return 1
end sub
Известная архитектурная слабость (O(N²))

В коде есть функция find_client$(id$). Она читает файл clients.csv построчно с самого начала.
И вот в чем проблема. Когда мы генерируем отчет для всех клиентов (команда html.yab all), программа для каждого клиента вызывает client_exists(). А тот дергает find_client$().

То есть для 500 клиентов мы 500 раз прочитаем файл clients.csv с диска целиком. При большом количестве клиентов это превращается в десятки секунд ожидания. Решение (кэширование базы в памяти при инициализации) отложено до версии 0.2.0. В v0.1.0 живем так.

Полный листинг store.yab

Код: Выделить всё

// ============================================================
// store.yab — работа с базой данных клиентов
// ============================================================
//
// YABCT — Yet Another Body Composition Tool 
//
// Author:  Anton Shekhetov, Ekaterina Yakovleva
// Copyright (c) 2026 ANO DPO "NCPS"  RU INN 7606135591
// This software is released under the MIT License.
// See the LICENSE file in the project root for more information
//
// Импортирует csv.yab для парсинга CSV.
// Экспортирует функции для main-скриптов (new.yab, html.yab).
//
// Правило: store НЕ импортирует calc, cfg, lang.
//
// Ключевые решения:
//   - ID клиента = локальный unix-like timestamp (не UTC).
//     Используется только для уникальности, не для дат.
//   - add_client возвращает 0 при дубликате ID.
//   - update_client_field делает .bak перед перезаписью.
//   - В импортированных подпрограммах НЕ использовать break.
// ============================================================

import csv

store_base$ = "clients"

CLIENTS_HEADER$ = "id,sex,last_name,first_name,middle_name,birth_date,phone,email,notes,created,updated"

VISIT_HEADER$ = "visit_date,age,weight,tem_status,height,neck,waist,hip,arm_circ,thigh_circ,calf_circ,biceps,triceps,subscapular,suprailiac,iliac_crest,supraspinale,chest,abdomen,thigh,midaxillary,calf_medial,bf_dw,bf_jp7,bf_navy,bf_main,sm_lee,sm_janssen,fat_kg,lean_kg,bmi,whr,whtr,bsa,conditions_ok,visit_notes,sum8"

dim sv_arr$(20)
dim sv_lines$(10000)

// Коды и детали последней ошибки. Читаются через last_error_code$()
// и last_error_detail$() после того, как функция вернула 0.
store_err_code$ = ""
store_err_detail$ = ""

sub last_error_code$()
    return store_err_code$
end sub

sub last_error_detail$()
    return store_err_detail$
end sub

// Внутренняя: установить код ошибки.
sub set_error(code$, detail$)
    store_err_code$ = code$
    store_err_detail$ = detail$
end sub

// Внутренняя: очистить ошибку при успехе.
sub clear_error()
    store_err_code$ = ""
    store_err_detail$ = ""
end sub

// ============================================================
// ВНУТРЕННИЕ УТИЛИТЫ
// ============================================================

sub file_exists(fname$)
    local f
    f = open(fname$, "r")
    if (f = 0) return 0
    close f
    return 1
end sub

sub clients_file$()
    return store_base$ + ".csv"
end sub

sub visits_file$(id$)
    return id$ + ".csv"
end sub

sub pad2$(n)
    local s$
    s$ = trim$(str$(n))
    if (len(s$) = 1) then
        s$ = "0" + s$
    end if
    return s$
end sub

sub current_date$()
    local parts$(10)
    local m, d, y, dummy
    dummy = split(date$, parts$(), "-")
    m = val(parts$(2))
    d = val(parts$(3))
    y = val(parts$(4))
    return trim$(str$(y)) + "-" + pad2$(m) + "-" + pad2$(d)
end sub

// Локальный unix-like timestamp.
// Не настоящий UTC — просто число, уникальное в пределах секунды.
// Достаточно для генерации ID клиента.
sub get_local_timestamp()
    local parts$(10)
    local month, day, year, hour, minute, second
    local a, y, m, total_days, dummy

    dummy = split(date$, parts$(), "-")
    month = val(parts$(2))
    day   = val(parts$(3))
    year  = val(parts$(4))

    dummy = split(time$, parts$(), "-")
    hour   = val(parts$(1))
    minute = val(parts$(2))
    second = val(parts$(3))

    a = int((14 - month) / 12)
    y = year + 4800 - a
    m = month + 12 * a - 3
    total_days = day + int((153 * m + 2) / 5) + 365 * y + int(y / 4) - int(y / 100) + int(y / 400) - 32045

    return (total_days - 2440588) * 86400 + hour * 3600 + minute * 60 + second
end sub

// Преобразовать большое целое в строку без научной нотации.
// str$(1789250000) даёт "1.78925e+09" — точка и + в имени файла
// ломают Yabasic.
sub int_to_str$(n)
    local s$, remainder, neg

    neg = 0
    if (n < 0) then
        neg = 1
        n = -n
    end if

    if (n = 0) return "0"

    s$ = ""
    while (n > 0)
        remainder = n - int(n / 10) * 10
        s$ = chr$(48 + remainder) + s$
        n = int(n / 10)
    wend

    if (neg = 1) then
        s$ = "-" + s$
    end if
    return s$
end sub

// Копирование файла построчно. Для текстовых CSV.
sub copy_file(src$, dst$)
    local line$
    if (not file_exists(src$)) return 0
    open src$ for reading as #1
    open dst$ for writing as #2
    while (not eof(#1))
        line input #1 line$
        print #2, line$
    wend
    close #1
    close #2
    return 1
end sub

// ============================================================
// ИНИЦИАЛИЗАЦИЯ
// ============================================================

sub init(base$)
    if (base$ = "") then
        set_error("empty_input", "base")
        return 0
    end if
    store_base$ = base$
    clear_error()
    return 1
end sub

// ============================================================
// ОБЁРТКИ НАД CSV
// ============================================================
//
// main-скрипты (new.yab, html.yab) не могут импортировать csv.yab
// напрямую — csv уже загружен внутри store, повторный import
// вызовет duplicate subroutine.

sub build_line$(arr$(), n)
    return csv.build$(arr$(), n)
end sub

// ============================================================
// ЧТЕНИЕ
// ============================================================

sub find_client$(id$)
    local line$, first, result$, found
    if (not file_exists(clients_file$())) return ""

    result$ = ""
    first = 1
    found = 0
    open clients_file$() for reading as #1
    while (not eof(#1))
        line input #1 line$
        if (first = 1) then
            first = 0
        else
            if (found = 0) then
                csv.split_line(line$)
                if (csv.get$(1) = id$) then
                    result$ = line$
                    found = 1
                end if
            end if
        end if
    wend
    close #1
    return result$
end sub

sub client_exists(id$)
    if (find_client$(id$) = "") return 0
    return 1
end sub

sub field_index(name$)
    if (name$ = "id")          return 1
    if (name$ = "sex")         return 2
    if (name$ = "last_name")   return 3
    if (name$ = "first_name")  return 4
    if (name$ = "middle_name") return 5
    if (name$ = "birth_date")  return 6
    if (name$ = "phone")       return 7
    if (name$ = "email")       return 8
    if (name$ = "notes")       return 9
    if (name$ = "created")     return 10
    if (name$ = "updated")     return 11
    return 0
end sub

sub client_field$(id$, field_name$)
    local line$, idx
    line$ = find_client$(id$)
    if (line$ = "") return ""
    idx = field_index(field_name$)
    if (idx = 0) return ""
    csv.split_line(line$)
    return csv.get$(idx)
end sub

sub read_visits(id$, lines$(), max_n)
    local fname$, line$, n, first
    fname$ = visits_file$(id$)
    if (not file_exists(fname$)) return 0

    n = 0
    first = 1
    open fname$ for reading as #1
    while (not eof(#1))
        line input #1 line$
        if (first = 1) then
            first = 0
        else
            n = n + 1
            if (n <= max_n) then
                lines$(n) = line$
            end if
        end if
    wend
    close #1
    return n
end sub

// ============================================================
// ЗАПИСЬ
// ============================================================

sub add_client(id$, sex$, last$, first$, middle$, birth$, phone$, email$, notes$)
    local fname$, today$, line$

    if (id$ = "") then
        set_error("empty_input", "id")
        return 0
    end if

    if (client_exists(id$) = 1) then
        set_error("duplicate_client", id$)
        return 0
    end if

    fname$ = clients_file$()

    if (not file_exists(fname$)) then
        open fname$ for writing as #1
        print #1, CLIENTS_HEADER$
        close #1
    end if

    sv_arr$(1)  = id$
    sv_arr$(2)  = sex$
    sv_arr$(3)  = last$
    sv_arr$(4)  = first$
    sv_arr$(5)  = middle$
    sv_arr$(6)  = birth$
    sv_arr$(7)  = phone$
    sv_arr$(8)  = email$
    sv_arr$(9)  = notes$
    today$ = current_date$()
    sv_arr$(10) = today$
    sv_arr$(11) = today$

    line$ = csv.build$(sv_arr$(), 11)

    open #1, fname$, "a"
    print #1, line$
    close #1
    clear_error()
    return 1
end sub

sub update_client_field(id$, field_name$, value$)
    local fname$, bakname$, n, i, j, line$, idx, found
    fname$ = clients_file$()
    if (not file_exists(fname$)) then
        set_error("file_not_found", fname$)
        return 0
    end if

    idx = field_index(field_name$)
    if (idx = 0) then
        set_error("unknown_field", field_name$)
        return 0
    end if

    n = 0
    open fname$ for reading as #1
    while (not eof(#1))
        line input #1 line$
        n = n + 1
        if (n <= 10000) then
            sv_lines$(n) = line$
        else
            close #1
            set_error("overflow_rows", "")
            return 0
        end if
    wend
    close #1

    found = 0
    for i = 2 to n
        csv.split_line(sv_lines$(i))
        if (csv.get$(1) = id$) then
            for j = 1 to 11
                sv_arr$(j) = csv.get$(j)
            next j
            sv_arr$(idx) = value$
            sv_arr$(11) = current_date$()
            sv_lines$(i) = csv.build$(sv_arr$(), 11)
            found = 1
        end if
    next i

    if (found = 0) then
        set_error("client_not_found", id$)
        return 0
    end if

    bakname$ = fname$ + ".bak"
    copy_file(fname$, bakname$)

    open fname$ for writing as #1
    for i = 1 to n
        print #1, sv_lines$(i)
    next i
    close #1
    clear_error()
    return 1
end sub

sub add_visit(id$, row$)
    local fname$

    if (id$ = "") then
        set_error("empty_input", "id")
        return 0
    end if
    if (row$ = "") then
        set_error("empty_input", "row")
        return 0
    end if

    fname$ = visits_file$(id$)
    if (not file_exists(fname$)) then
        open fname$ for writing as #1
        print #1, VISIT_HEADER$
        close #1
    end if

    open #1, fname$, "a"
    print #1, row$
    close #1
    clear_error()
    return 1
end sub

// ============================================================
// ГЕНЕРАЦИЯ ID
// ============================================================

sub new_id$()
    return int_to_str$(get_local_timestamp())
end sub

// ============================================================
// ФУНКЦИИ ДЛЯ MAIN-СКРИПТОВ (html.yab)
// ============================================================

sub list_ids(ids$(), max_n)
    local line$, first, n
    if (not file_exists(clients_file$())) return 0
    n = 0
    first = 1
    open clients_file$() for reading as #1
    while (not eof(#1))
        line input #1 line$
        if (first = 1) then
            first = 0
        else
            n = n + 1
            if (n <= max_n) then
                csv.split_line(line$)
                ids$(n) = csv.get$(1)
            end if
        end if
    wend
    close #1
    return n
end sub

sub parse_row(line$)
    return csv.split_line(line$)
end sub

sub row_field$(n)
    return csv.get$(n)
end sub
Аватара пользователя
Anton
Site Admin
Сообщения: 254
Зарегистрирован: Чт фев 08, 2024 7:03 pm
Откуда: Москва

Математика и округление

Сообщение Anton »

Модуль calc.yab — самый независимый в проекте. Он не импортирует ничего: ни csv, ни store. Только чистые формулы. Сделано это для того, чтобы математику можно было протестировать в отрыве от файловой системы (что я и сделал в test_calc.yab).

В Yabasic нет исключений (try/catch), поэтому если формуле не хватает данных (например, врач не замерил складку или ввел ноль), функция не должна ронять программу. Все функции в calc возвращают -1, если расчет невозможен. Вызывающий код уже сам решает: выводить пустую строку в отчёт или показать прочерк.

Грабля: int() врет на отрицательных числах

Это была одна из самых неочевидных проблем. В Yabasic есть встроенная функция int(). Она усекает число к нулю, а не округляет по математическим правилам.
Если попытаться сделать классическое округление до одного знака через int(x * 10 + 0.5) / 10, то для положительных чисел это сработает. А для отрицательных — нет.

Например, нужно округлить -13.614. Умножаем на 10, прибавляем 0.5: получается -135.64. Функция int() отбрасывает дробную часть и возвращает -135. Делим на 10 — получаем -13.5. Но мы-то хотели -13.6.

Пришлось писать свою функцию с использованием модуля числа (abs) и определения знака (sgn):

Код: Выделить всё

sub sgn(x)
    if (x > 0) return 1
    if (x < 0) return -1
    return 0
end sub

sub round1(x)
    return sgn(x) * int(abs(x) * 10 + 0.5) / 10
end sub
Теперь round1(-13.614) корректно возвращает -13.6.

Константа pi

Из приятного: в Yabasic pi — это встроенная константа. Объявлять её не нужно. Это пригодилось в формуле Lee (расчет мышечной массы), где обхваты нужно корректировать на толщину складки через pi * (triceps_sf / 10).

Полный листинг calc.yab

Код: Выделить всё

// ============================================================
// calc.yab — формулы расчёта состава тела
// ============================================================
//
// YABCT — Yet Another Body Composition Tool 
//
// Author:  Anton Shekhetov, Ekaterina Yakovleva
// Copyright (c) 2026 ANO DPO "NCPS"  RU INN 7606135591
// This software is released under the MIT License.
// See the LICENSE file in the project root for more information
//
// ЧИСТАЯ МАТЕМАТИКА. Не импортирует другие модули проекта.
// Пороги категорий передаются снаружи (из cfg в main).
//
// Все функции возвращают "сырые" значения без округления.
// Округление — задача интерфейса/отчёта.
// При некорректных входных данных возвращается -1.
//
// Формулы (источники):
//   Durnin-Womersley 4-site: Durnin & Womersley, 1974
//   Jackson-Pollock 7-site: Jackson & Pollock, 1978
//   US Navy: Hodgdon & Beckett, 1984
//   Lee SMM: Lee et al., 2000
//   Janssen SMM: Janssen et al., 2000
//   BSA (Du Bois): Du Bois & Du Bois, 1916 
//   TEM intra-session: адаптация ISAK (PROJECT_STATE §3.12)
//
// Версия формул: 1.0
//
// Примечание: pi — встроенная константа Yabasic.
// ============================================================

// ============================================================
// ЖИРОВАЯ МАССА (BF%)
// ============================================================

// ------------------------------------------------------------
// skinfolds(sex$, biceps, triceps, subscap, suprail)
// Durnin-Womersley 4-site.
// Складки в мм. Возвращает BF% или -1.
// ------------------------------------------------------------
sub skinfolds(sex$, biceps, triceps, subscap, suprail)
    local sum_sf, log10s, bd

    // Все 4 складки обязательны
    if (biceps <= 0 or triceps <= 0 or subscap <= 0 or suprail <= 0) return -1

    sum_sf = biceps + triceps + subscap + suprail
    log10s = log(sum_sf) / log(10)

    if (sex$ = "m" or sex$ = "M") then
        bd = 1.1765 - 0.0744 * log10s
    elsif (sex$ = "f" or sex$ = "F") then
        bd = 1.1567 - 0.0717 * log10s
    else
        return -1
    end if

    return 495 / bd - 450
end sub

// ------------------------------------------------------------
// navy(sex$, height, neck, waist, hip)
// US Navy. Размеры в см. hip для женщин обязателен.
// Возвращает BF% или -1.
// ------------------------------------------------------------
sub navy(sex$, height, neck, waist, hip)
    local denom, log_w_neck, log_h, log_wh_neck

    if (height <= 0 or neck <= 0 or waist <= 0) return -1

    if (sex$ = "m" or sex$ = "M") then
        if (waist <= neck) return -1
        log_w_neck = log(waist - neck) / log(10)
        log_h = log(height) / log(10)
        denom = 1.0324 - 0.19077 * log_w_neck + 0.15456 * log_h

    elsif (sex$ = "f" or sex$ = "F") then
        if (hip <= 0) return -1
        if (waist + hip <= neck) return -1
        log_wh_neck = log(waist + hip - neck) / log(10)
        log_h = log(height) / log(10)
        denom = 1.29579 - 0.35004 * log_wh_neck + 0.22100 * log_h

    else
        return -1
    end if

    if (denom <= 0) return -1
    return 495 / denom - 450
end sub

// ------------------------------------------------------------
// jp7(sex$, age, s1..s7)
// Jackson-Pollock 7-site.
// Точки: chest, midaxillary, triceps, subscapular,
//        abdomen, suprailiac, thigh
// Возвращает BF% или -1.
// ------------------------------------------------------------
sub jp7(sex$, age, s1, s2, s3, s4, s5, s6, s7)
    local sum_sf, sum_sq, bd

    if (age <= 0) return -1

    // Все 7 складок обязательны — иначе формула даёт мусор
    if (s1 <= 0 or s2 <= 0 or s3 <= 0 or s4 <= 0) return -1
    if (s5 <= 0 or s6 <= 0 or s7 <= 0) return -1

    sum_sf = s1 + s2 + s3 + s4 + s5 + s6 + s7
    sum_sq = sum_sf * sum_sf
    if (sex$ = "m" or sex$ = "M") then
        bd = 1.112 - 0.00043499 * sum_sf + 0.00000055 * sum_sq - 0.00028826 * age
    elsif (sex$ = "f" or sex$ = "F") then
        bd = 1.097 - 0.00046971 * sum_sf + 0.00000056 * sum_sq - 0.00012828 * age
    else
        return -1
    end if

    if (bd <= 0) return -1
    return 495 / bd - 450
end sub

// ============================================================
// СУММЫ ISAK
// ============================================================

// ------------------------------------------------------------
// sum8(triceps, subscapular, biceps, iliac_crest, supraspinale,
//      abdominal, thigh, calf_medial)
// Σ8 ISAK — сумма 8 складок (мм), официальный набор.
// Все точки > 0, иначе -1.
// ------------------------------------------------------------
sub sum8(triceps, subscapular, biceps, iliac_crest, supraspinale, abdominal, thigh, calf_medial)
    if (triceps <= 0 or subscapular <= 0 or biceps <= 0 or iliac_crest <= 0) return -1
    if (supraspinale <= 0 or abdominal <= 0 or thigh <= 0 or calf_medial <= 0) return -1
    return triceps + subscapular + biceps + iliac_crest + supraspinale + abdominal + thigh + calf_medial
end sub

// ============================================================
// МЫШЕЧНАЯ МАССА (SMM)
// ============================================================

// ------------------------------------------------------------
// lee(sex$, age, height_m, arm_circ, thigh_circ, calf_circ,
//     triceps_sf, thigh_sf, calf_sf)
// Lee et al. (2000), модель 1.
// Все обхваты — см, складки — мм, рост — метры.
// Возвращает SMM в кг или -1.
// ------------------------------------------------------------
sub lee(sex$, age, height_m, arm_circ, thigh_circ, calf_circ, triceps_sf, thigh_sf, calf_sf)
    local cag, ctg, ccg, sex_v, sm

    if (height_m <= 0 or age <= 0) return -1
    if (arm_circ <= 0 or thigh_circ <= 0 or calf_circ <= 0) return -1
    if (triceps_sf <= 0 or thigh_sf <= 0 or calf_sf <= 0) return -1

    // Скорректированные обхваты: circ - π × skinfold_m
    cag = arm_circ   - pi * (triceps_sf / 10)
    ctg = thigh_circ - pi * (thigh_sf / 10)
    ccg = calf_circ  - pi * (calf_sf / 10)

    if (cag <= 0 or ctg <= 0 or ccg <= 0) return -1

    if (sex$ = "m" or sex$ = "M") then
        sex_v = 1
    else
        sex_v = 0
    end if

    sm = height_m * (0.00744 * cag * cag + 0.00088 * ctg * ctg + 0.00441 * ccg * ccg)
    sm = sm + 2.4 * sex_v - 0.048 * age + 7.8

    if (sm <= 0) return -1
    return sm
end sub

// ------------------------------------------------------------
// janssen(sex$, age, weight, height_m)
// Janssen et al. (2000). Возвращает SMM в кг или -1.
// ------------------------------------------------------------
sub janssen(sex$, age, weight, height_m)
    local sex_v, sm

    if (weight <= 0 or height_m <= 0 or age <= 0) return -1

    if (sex$ = "m" or sex$ = "M") then
        sex_v = 1
    else
        sex_v = 0
    end if

    sm = 0.244 * weight + 7.8 * height_m - 0.098 * age + 6.6 * sex_v - 3.3

    if (sm <= 0) return -1
    return sm
end sub

// ============================================================
// СПРАВОЧНЫЕ ПОКАЗАТЕЛИ
// ============================================================

// BMI. weight в кг, height в см.
sub bmi(weight, height)
    local h_m
    if (weight <= 0 or height <= 0) return -1
    h_m = height / 100
    return weight / (h_m * h_m)
end sub

// BSA (Du Bois). weight в кг, height в см.
sub bsa(weight, height)
    if (weight <= 0 or height <= 0) return -1
    return 0.007184 * (height ^ 0.725) * (weight ^ 0.425)
end sub

// WHR (waist-to-hip ratio).
sub whr(waist, hip)
    if (waist <= 0 or hip <= 0) return -1
    return waist / hip
end sub

// WHtR (waist-to-height ratio). height в см.
sub whtr(waist, height)
    if (waist <= 0 or height <= 0) return -1
    return waist / height
end sub

// ============================================================
// TEM — КОНТРОЛЬ КАЧЕСТВА ИЗМЕРЕНИЙ
// ============================================================
//
// Внутрисессионный rTEM. См. PROJECT_STATE §3.12.
// Протокол повторов: k ∈ [2, 5]. При k < 2 — ошибка ввода
// (ловится выше, в парсере; здесь защита от прямого вызова).
//
// Массивы values$() заполняются снаружи (парсер в new.yab).
// dim в sub запрещён (грабля 10) — только чтение переданного
// массива.

// ------------------------------------------------------------
// tem_mean(values$(), n)
// Среднее арифметическое по массиву строк. n >= 2.
// Возвращает mean или -1 при ошибке.
// ------------------------------------------------------------
sub tem_mean(values$(), n)
    local i, sum, v

    if (n < 2) return -1

    sum = 0
    for i = 1 to n
        v = val(values$(i))
        if (v < 0) return -1
        sum = sum + v
    next i

    return sum / n
end sub

// ------------------------------------------------------------
// tem_sd(values$(), n, mean)
// Выборочное стандартное отклонение (знаменатель k-1).
// n >= 2. mean > 0. Возвращает SD или -1.
// ------------------------------------------------------------
sub tem_sd(values$(), n, mean)
    local i, sum_sq, v, d

    if (n < 2) return -1
    if (mean <= 0) return -1

    sum_sq = 0
    for i = 1 to n
        v = val(values$(i))
        if (v < 0) return -1
        d = v - mean
        sum_sq = sum_sq + d * d
    next i

    return sqrt(sum_sq / (n - 1))
end sub

// ------------------------------------------------------------
// tem_rtem(mean, sd)
// rTEM = SD / mean × 100%. Возвращает % или -1.
// ------------------------------------------------------------
sub tem_rtem(mean, sd)
    if (mean <= 0) return -1
    if (sd < 0) return -1
    return sd / mean * 100
end sub

// ============================================================
// КАТЕГОРИИ
// ============================================================

// ------------------------------------------------------------
// category$(bf, sex$, e, a, f, av)
// Категория BF% по ACSM. Пороги передаются снаружи.
// ------------------------------------------------------------
sub category$(bf, sex$, e, a, f, av)
    if (bf < 0) return ""
    if (sex$ <> "m" and sex$ <> "M" and sex$ <> "f" and sex$ <> "F") return ""

    if (bf < e)  return "Essential"
    if (bf < a)  return "Athlete"
    if (bf < f)  return "Fitness"
    if (bf < av) return "Average"
    return "Obese"
end sub

// ------------------------------------------------------------
// bmi_category$(bmi_val, under, normal, over)
// ------------------------------------------------------------
sub bmi_category$(bmi_val, under, normal, over)
    if (bmi_val < 0) return ""
    if (bmi_val < under)  return "Underweight"
    if (bmi_val < normal) return "Normal"
    if (bmi_val < over)   return "Overweight"
    return "Obese"
end sub

// ------------------------------------------------------------
// body_type$(whr_val, sex$)
// Тип телосложения по WHR.
// Андроидный (яблоко) — риск ССЗ.
// Гиноидный (груша) — метаболически нейтральнее.
// ------------------------------------------------------------
sub body_type$(whr_val, sex$)
    local android_lim, gynoid_lim

    if (whr_val < 0) return ""

    if (sex$ = "m" or sex$ = "M") then
        android_lim = 0.90
        gynoid_lim = 0.85
    elsif (sex$ = "f" or sex$ = "F") then
        android_lim = 0.85
        gynoid_lim = 0.80
    else
        return ""
    end if

    if (whr_val > android_lim) return "Android"
    if (whr_val < gynoid_lim)  return "Gynoid"
    return "Intermediate"
end sub

// ============================================================
// УТИЛИТЫ
// ============================================================

// ------------------------------------------------------------
// mifflin_bmr(sex$, weight, height_cm, age)
// Mifflin-St Jeor (1990). Возвращает ккал/сутки или -1.
// ------------------------------------------------------------
sub mifflin_bmr(sex$, weight, height_cm, age)
    local s, r
    if (weight <= 0 or height_cm <= 0 or age <= 0) return -1
    if (sex$ = "m" or sex$ = "M") then
        s = 5
    elsif (sex$ = "f" or sex$ = "F") then
        s = -161
    else
        return -1
    end if
    r = 10 * weight + 6.25 * height_cm - 5 * age + s
    if (r <= 0) return -1
    return r
end sub


// ============================================================
// УТИЛИТЫ ОКРУГЛЕНИЯ
// ============================================================

// Знак числа: 1, -1 или 0.
sub sgn(x)
    if (x > 0) return 1
    if (x < 0) return -1
    return 0
end sub

// Округление до 1 знака (half-up, корректно для отрицательных).
// int() в Yabasic усекает к нулю — для отрицательных используем sgn/abs.
sub round1(x)
    return sgn(x) * int(abs(x) * 10 + 0.5) / 10
end sub

// Округление до 2 знаков (half-up, корректно для отрицательных).
sub round2(x)
    return sgn(x) * int(abs(x) * 100 + 0.5) / 100
end sub
Аватара пользователя
Anton
Site Admin
Сообщения: 254
Зарегистрирован: Чт фев 08, 2024 7:03 pm
Откуда: Москва

Конфигурация, локализация и отсутствующий replace$

Сообщение Anton »

Для нормальной работы программы нужны два текстовых файла: config.cfg (пороги, лимиты, названия клиник) и lang.txt (переводы интерфейса). Оба парсятся одинаково: читаем файл построчно, отбрасываем пустые строки и комментарии (начинающиеся с #), ищем первое вхождение = и разбиваем строку на ключ и значение.

Однако в локализации есть нюанс. Шаблоны в lang.txt содержат плейсхолдеры. Например, app.client_id=ID клиента: {x}. Программе нужно заменить {x} на реальный ID.

Грабля: где replace$?

В современных языках это делается одной строчкой. В Yabasic функции replace$() нет (она появилась только в версии 2.92.0, а проект пишется под 2.91.1+).

Поэтому замену приходится делать руками через instr (поиск подстроки) и склейку через left$ и mid$:

Код: Выделить всё

sub subst$(key$, value$)
    local s$, pos
    s$ = t$(key$)
    pos = instr(s$, "{x}")
    if (pos = 0) return s$
    return left$(s$, pos - 1) + value$ + mid$(s$, pos + 3)
end sub
Важное ограничение: эта функция заменяет только первое вхождение {x}. Если в шаблоне их несколько, остальные останутся как есть. Для моих задач (ID клиента или число визитов) этого хватило. Писать цикл для замены всех вхождений через mid$ — это гарантированный O(n²) на длинных строках, а мне нужно было сохранить хоть какую-то скорость.

Обработка отсутствующих ключей

В cfg.yab если ключа нет, возвращается пустая строка (или 0 для чисел). Это нормально для настроек: если забыл указать weight_max, просто не будет работат валидация.

А вот в lang.yab я сделал иначе. Если перевод не найден, функция t$() возвращает строку вида ?no.such.key?. Это сделано специально: чтобы при разработке сразу бросалось в глаза, что перевод забыли добавить.

Дубликаты ключей

Есть нюанс, который я сознательно оставил как есть. Если в config.cfg дважды написать weight_max=100 и weight_max=200, парсер запомнит оба. Но функция get$ возвращает первое найденное значение (100). Второе просто проигнорируется. Никаких предупреждений не выводится.

Полные листинги
cfg.yab

Код: Выделить всё

// ============================================================
// cfg.yab — парсер конфигурационного файла
// ============================================================
//
// YABCT — Yet Another Body Composition Tool 
//
// Author:  Anton Shekhetov, Ekaterina Yakovleva
// Copyright (c) 2026 ANO DPO "NCPS"  RU INN 7606135591
// This software is released under the MIT License.
// See the LICENSE file in the project root for more information
//
// Использование из главной программы:
//     import cfg
//     n = cfg.load("config.cfg")
//     w = cfg.get_num("weight_max")
//     u$ = cfg.get$("units")
//
// Встроенная trim$() из Yabasic обрезает пробелы и табуляции
// с обоих концов строки. Свою писать не нужно.
// ============================================================

dim cfg_key$(200)
dim cfg_val$(200)
cfg_count = 0

// ------------------------------------------------------------
// load(fname$) — читает файл, заполняет массивы.
// Возвращает число прочитанных пар или -1 при ошибке.
// ------------------------------------------------------------
sub load(fname$)
    local line$, key$, val$, p, i, f

    // Yabasic падает на `open ... for reading`, если файла нет.
    // Открываем через `f = open(...)` — тогда вернётся 0.
    f = open(fname$, "r")
    if (f = 0) then
        cfg_count = 0
        return 0
    end if
    close f

    open fname$ for reading as #1

    cfg_count = 0

    while (not eof(#1))
        line input #1 line$
        line$ = trim$(line$)          // встроенная функция

        if (len(line$) > 0 and left$(line$, 1) <> "#") then

            p = 0
            for i = 1 to len(line$)
                if (mid$(line$, i, 1) = "=" and p = 0) p = i
            next i

            if (p > 0) then
                key$ = trim$(left$(line$, p - 1))
                val$ = trim$(mid$(line$, p + 1))
                cfg_count = cfg_count + 1
                cfg_key$(cfg_count) = key$
                cfg_val$(cfg_count) = val$
            else
                // Диагностика: строка без '='. Пишем в stdout,
                // чтобы разработчик видел. Пользователю не мешает.
                print "[cfg] Skipping line without '=': ", line$
            end if
        end if
    wend

    close #1
    return cfg_count
end sub

// ------------------------------------------------------------
// get$(key$) — строковое значение по ключу.
// ------------------------------------------------------------
sub get$(key$)
    local i
    for i = 1 to cfg_count
        if (cfg_key$(i) = key$) return cfg_val$(i)
    next i
    return ""
end sub

// ------------------------------------------------------------
// get_num(key$) — числовое значение по ключу.
// Если ключа нет или значение не число — 0.
// ------------------------------------------------------------
sub get_num(key$)
    local v$
    v$ = get$(key$)
    if (v$ = "") return 0
    return val(v$)
end sub

// ------------------------------------------------------------
// has(key$) — есть ли ключ. Возвращает 1 или 0.
// ------------------------------------------------------------
sub has(key$)
    local i
    for i = 1 to cfg_count
        if (cfg_key$(i) = key$) return 1
    next i
    return 0
end sub

// ------------------------------------------------------------
// dump() — диагностика, печатает все пары.
// ------------------------------------------------------------
sub dump()
    local i
    print "--- cfg dump (", cfg_count, " pairs) ---"
    for i = 1 to cfg_count
        print "  [", cfg_key$(i), "] = [", cfg_val$(i), "]"
    next i
    print "--- end ---"
end sub
lang.yab

Код: Выделить всё

// ============================================================
// lang.yab — парсер файла локализации
// ============================================================
//
// YABCT — Yet Another Body Composition Tool 
//
// Author:  Anton Shekhetov, Ekaterina Yakovleva
// Copyright (c) 2026 ANO DPO "NCPS"  RU INN 7606135591
// This software is released under the MIT License.
// See the LICENSE file in the project root for more information
//
// Использование из главной программы:
//     import lang
//     n = lang.load("lang.txt")
//     s$ = lang.t$("menu.exit")
//
// Особенности:
//     - Если ключа нет — t$ возвращает "?key?" (заметно на экране).
//     - Пустое значение (key=) допустимо и вернётся как "".
//     - load() полностью заменяет предыдущий набор пар.
//       Переключение языка = повторный load() с другим файлом.
// ============================================================

dim lang_key$(500)
dim lang_val$(500)
lang_count = 0

// ------------------------------------------------------------
// load(fname$) — читает файл локализации.
// Возвращает число прочитанных пар.
// ------------------------------------------------------------
sub load(fname$)
    local line$, key$, val$, p, i, f

    // Yabasic падает на `open ... for reading`, если файла нет.
    // Открываем через `f = open(...)` — тогда вернётся 0.
    f = open(fname$, "r")
    if (f = 0) then
        lang_count = 0
        return 0
    end if
    close f

    open fname$ for reading as #1

    lang_count = 0

    while (not eof(#1))
        line input #1 line$
        line$ = trim$(line$)

        if (len(line$) > 0 and left$(line$, 1) <> "#") then

            // Ищем первый "="
            p = 0
            for i = 1 to len(line$)
                if (mid$(line$, i, 1) = "=" and p = 0) p = i
            next i

            if (p > 0) then
                key$ = trim$(left$(line$, p - 1))
                val$ = trim$(mid$(line$, p + 1))
                // В отличие от cfg, пустое значение — допустимо.
                // Проверяем только непустой ключ.
                if (len(key$) > 0) then
                    lang_count = lang_count + 1
                    lang_key$(lang_count) = key$
                    lang_val$(lang_count) = val$
                end if
            else
                // Диагностика: строка без '='. Пишем в stdout.
                print "[lang] Skipping line without '=': ", line$
            end if
        end if
    wend

    close #1
    return lang_count
end sub

// ------------------------------------------------------------
// t$(key$) — перевод по ключу.
// Если ключа нет — возвращает "?key?" для отладки.
// ------------------------------------------------------------
sub t$(key$)
    local i
    for i = 1 to lang_count
        if (lang_key$(i) = key$) return lang_val$(i)
    next i
    return "?" + key$ + "?"
end sub

// ------------------------------------------------------------
// has(key$) — есть ли ключ (для проверки полноты перевода).
// ------------------------------------------------------------
sub has(key$)
    local i
    for i = 1 to lang_count
        if (lang_key$(i) = key$) return 1
    next i
    return 0
end sub

// ------------------------------------------------------------
// dump() — диагностика: печатает все пары.
// ------------------------------------------------------------
sub dump()
    local i
    print "--- lang dump (", lang_count, " pairs) ---"
    for i = 1 to lang_count
        print "  [", lang_key$(i), "] = [", lang_val$(i), "]"
    next i
    print "--- end ---"
end sub

// ------------------------------------------------------------
// subst$(key$, value$)
// Заменить первое "{x}" в шаблоне key$ на value$.
// Если ключа нет — возвращает ?key? без подстановки.
// Если в шаблоне нет "{x}" — возвращает шаблон как есть.
// ------------------------------------------------------------
sub subst$(key$, value$)
    local s$, pos
    s$ = t$(key$)
    pos = instr(s$, "{x}")
    if (pos = 0) return s$
    return left$(s$, pos - 1) + value$ + mid$(s$, pos + 3)
end sub
Аватара пользователя
Anton
Site Admin
Сообщения: 254
Зарегистрирован: Чт фев 08, 2024 7:03 pm
Откуда: Москва

Модуль new.yab

Сообщение Anton »

Это самый «толстый» скрипт из всей бизнес-логики. Он принимает на вход input.txt, проверяет данные, считает возраст, запускает математику из calc.yab и сохраняет всё через store.yab. Здесь же живёт логика TEM-замеров (когда врач делает несколько замеров одной складки через ";").

1. Валидация и главная засада с val()

В Yabasic нет исключений. Если пользователь введет в поле weight: abc, программа не должна молча записать это в базу или упасть с невнятным стектрейсом.

Казалось бы, для проверки на число есть встроенная функция val(). Но проблема в том, что val("abc") возвращает 0. И тут возникает вопрос: а если пользователь реально ввел 0? Как отличить корректный ноль от мусора?

Пришлось писать свою функцию is_number():

Код: Выделить всё

sub is_number(s$)
    local i, c$, dots, result
    if (len(s$) = 0) return 0
    dots = 0
    result = 1
    for i = 1 to len(s$)
        c$ = mid$(s$, i, 1)
        if (c$ = ".") then
            dots = dots + 1
            if (dots > 1) result = 0
        elsif (c$ = "-") then
            if (i > 1) result = 0
        elsif (c$ < "0" or c$ > "9") then
            result = 0
        end if
    next i
    return result
end sub
2. Стратегия Fail-Hard
Если last_name пустое, или пол не m/f, или weight не число — программа не пытается «спасти» ситуацию. Она падает через fatal() и завершает работу.

Код: Выделить всё

if (in_get$("sex") <> "m" and in_get$("sex") <> "f") then
    fatal(lang.t$("validate.err_sex"))
end if
Почему так? Потому что это медицинский инструмент. Лучше заставить врача исправить опечатку в input.txt, чем записать в базу данных клиента с полом «х» и потом ловить баги при генерации отчёта.

3. Возраст и парсинг дат

В Yabasic нет библиотек для работы с датами. Встроенная date$ возвращает строку из 6 полей (типа 3-09-23-2026-Wed-Sep), которую надо разбивать руками.

Чтобы посчитать возраст на момент визита, я написал функцию calc_age(). Она берет дату рождения (ГГГГ-ММ-ДД) и дату визита, разбивает их через split() и вычитает года с учетом того, прошел ли уже день рождения в текущем году.

4. Режим «повторов» (TEM)

Если врач хочет оценить свою точность (TEM), он может ввести значение через точку с запятой: triceps: 6.0;6.2;5.8.
Функция in_values() разбирает такую строку. Если значение одно — возвращает его. Если несколько — считает среднее. А если значений больше 5 (или там буквы) — снова вызывает fatal().

5. Грабля с line input

Напомню про ограничение, о котором писал в посте про CSV. Функция line input читает строку до первого перевода строки (\n). Если врач нажмет Enter внутри поля notes (решит написать заметку в две строки), вторая строка просто потеряется. Программа не упадет, просто молча съест текст.

Полный листинг new.yab

Код: Выделить всё

// ============================================================
// new.yab — создание клиента / добавление визита из input.txt
// ============================================================
//
// YABCT — Yet Another Body Composition Tool 
//
// Author:  Anton Shekhetov, Ekaterina Yakovleva
// Copyright (c) 2026 ANO DPO "NCPS"  RU INN 7606135591
// This software is released under the MIT License.
// See the LICENSE file in the project root for more information
//
// Режимы:
//   input.txt содержит id: <число>		→ визит к существующему клиенту
//   input.txt без id								→ создание нового клиента
//
// Зависимости: cfg, calc, store, lang.
// ============================================================

import cfg
import calc
import store
import lang

// ============================================================
// ХРАНИЛИЩЕ INPUT
// ============================================================

dim in_key$(100)
dim in_val$(100)
in_count = 0

dim tmp_date$(10)
dim visit_arr$(40)

// Список TEM-точек. Порядок: складки (поз. 12–22), затем обхваты (поз. 6–11).
// См. PROJECT_STATE §3.12.
dim tem_list$(17)
dim tem_is_sf(17)   // 1 = складка, 0 = обхват

// ============================================================
// ФАТАЛЬНАЯ ОШИБКА
// ============================================================

sub fatal(msg$)
    print msg$
    exit
end sub

// Показать ошибку store и завершить.
// Использует err.<code> из lang, если ключ есть. Иначе — err.generic.
sub fatal_store_error()
    local code$, detail$, key$
    code$   = store.last_error_code$()
    detail$ = store.last_error_detail$()
    key$    = "err." + code$
    if (lang.has(key$) = 1) then
        fatal(lang.subst$(key$, detail$))
    end if
    fatal(lang.subst$("err.generic", code$))
end sub

// ============================================================
// УТИЛИТЫ
// ============================================================

sub file_exists(fname$)
    local f
    f = open(fname$, "r")
    if (f = 0) return 0
    close f
    return 1
end sub

sub strip_comment$(s$)
    local i, c$
    for i = 1 to len(s$)
        c$ = mid$(s$, i, 1)
        if (c$ = "#") return trim$(left$(s$, i - 1))
    next i
    return trim$(s$)
end sub

sub is_number(s$)
    local i, c$, dots, result
    if (len(s$) = 0) return 0
    dots = 0
    result = 1
    for i = 1 to len(s$)
        c$ = mid$(s$, i, 1)
        if (c$ = ".") then
            dots = dots + 1
            if (dots > 1) result = 0
        elsif (c$ = "-") then
            if (i > 1) result = 0
        elsif (c$ < "0" or c$ > "9") then
            result = 0
        end if
    next i
    return result
end sub

// ============================================================
// ПАРСЕР INPUT.TXT
// ============================================================

sub load_input(fname$)
    local line$, key$, val$, rest$, p, i

    if (not file_exists(fname$)) return 0

    in_count = 0
    open fname$ for reading as #1
    while (not eof(#1))
        line input #1 line$
        line$ = trim$(line$)

        if (len(line$) > 0 and left$(line$, 1) <> "#") then
            p = 0
            for i = 1 to len(line$)
                if (mid$(line$, i, 1) = ":" and p = 0) p = i
            next i

            if (p > 0) then
                key$ = trim$(left$(line$, p - 1))
                rest$ = mid$(line$, p + 1)
                val$ = strip_comment$(rest$)

                if (len(key$) > 0) then
                    in_count = in_count + 1
                    in_key$(in_count) = key$
                    in_val$(in_count) = val$
                end if
            end if
        end if
    wend
    close #1
    return in_count
end sub

sub in_has(key$)
    local i
    for i = 1 to in_count
        if (in_key$(i) = key$) return 1
    next i
    return 0
end sub

sub in_get$(key$)
    local i
    for i = 1 to in_count
        if (in_key$(i) = key$) return in_val$(i)
    next i
    return ""
end sub

sub in_num(key$)
    local v$
    if (not in_has(key$)) return 0
    v$ = in_get$(key$)
    if (not is_number(v$)) return 0
    return val(v$)
end sub

// ------------------------------------------------------------
// in_values(key$, arr$(), max_n)
// Разбор значения с повторами (разделитель ";") для точки с TEM.
// Возвращает k (число значений), заполняет arr$().
// Без ";" — k = 1, arr$(1) = значение.
// Нет ключа или пусто — k = 0.
// Ошибки ввода → fatal (fail-hard, §8.2 п. 11):
//   > max_n, пусто, не число, отрицательное.
// ------------------------------------------------------------
sub in_values(key$, arr$(), max_n)
    local raw$, parts$(10), k, i

    if (not in_has(key$)) return 0
    raw$ = in_get$(key$)
    if (raw$ = "") return 0

    // Одиночное значение
    if (instr(raw$, ";") = 0) then
        if (not is_number(raw$)) then
            fatal(lang.t$("err.tem_invalid") + " " + key$ + ": " + lang.t$("err.tem_reason_not_number"))
        end if
        if (val(raw$) < 0) then
            fatal(lang.t$("err.tem_invalid") + " " + key$ + ": " + lang.t$("err.tem_reason_negative"))
        end if
        arr$(1) = raw$
        return 1
    end if

    // Со ";" — split
    k = split(raw$, parts$(), ";")
    if (k > max_n) then
        fatal(lang.t$("err.tem_invalid") + " " + key$ + ": " + lang.t$("err.tem_reason_too_many"))
    end if
    if (k < 2) then
        fatal(lang.t$("err.tem_invalid") + " " + key$ + ": " + lang.t$("err.tem_reason_empty"))
    end if

    for i = 1 to k
        if (parts$(i) = "") then
            fatal(lang.t$("err.tem_invalid") + " " + key$ + ": " + lang.t$("err.tem_reason_empty"))
        end if
        if (not is_number(parts$(i))) then
            fatal(lang.t$("err.tem_invalid") + " " + key$ + ": " + lang.t$("err.tem_reason_not_number"))
        end if
        if (val(parts$(i)) < 0) then
            fatal(lang.t$("err.tem_invalid") + " " + key$ + ": " + lang.t$("err.tem_reason_negative"))
        end if
        arr$(i) = parts$(i)
    next i
    return k
end sub

// ------------------------------------------------------------
// get_point_value(key$)
// Значение точки для записи в CSV.
//   k = 0  → 0 (нет данных)
//   k = 1  → val(raw)
//   k >= 2 → calc.tem_mean (среднее по всем повторам)
// Детали TEM (k, значения) здесь не сохраняются —
// это делает блок 6 (<id>_tem.csv, tem_status).
// ------------------------------------------------------------
sub get_point_value(key$)
    local vals$(5), k
    k = in_values(key$, vals$(), 5)
    if (k = 0) return -1
    if (k = 1) return val(vals$(1))
    return calc.tem_mean(vals$(), k)
end sub

// ============================================================
// РАСЧЁТ ВОЗРАСТА
// ============================================================

sub calc_age(birth$, visit$)
    local by, bm, bd, vy, vm, vd, age, dummy

    if (len(birth$) < 10) return 0
    dummy = split(birth$, tmp_date$(), "-")
    by = val(tmp_date$(1))
    bm = val(tmp_date$(2))
    bd = val(tmp_date$(3))

    visit$ = left$(visit$, 10)
    dummy = split(visit$, tmp_date$(), "-")
    vy = val(tmp_date$(1))
    vm = val(tmp_date$(2))
    vd = val(tmp_date$(3))

    age = vy - by
    if (vm < bm) age = age - 1
    if (vm = bm and vd < bd) age = age - 1
    return age
end sub

// ============================================================
// ФОРМИРОВАНИЕ СТРОКИ ВИЗИТА
// ============================================================

sub fmt_num$(v)
    if (v < 0) return ""
    return trim$(str$(v))
end sub

sub fmt_field$(key$)
    local v$
    if (not in_has(key$)) return ""
    v$ = in_get$(key$)
    if (v$ = "") return ""
    if (not is_number(v$)) return ""
    return trim$(str$(val(v$)))
end sub

sub build_visit_row$(id$, sex$, age, visit_date$, tem_status$)
    local weight, height, neck, waist, hip
    local arm_circ, thigh_circ, calf_circ
    local biceps, triceps, subscap, suprail
    local iliac_crest, supraspinale
    local chest, abdomen, thigh_sf, midax, calf_sf
    local bf_dw, bf_jp7, bf_navy, bf_main
    local sm_lee, sm_janssen, fat_kg, lean_kg
    local bmi_v, whr_v, whtr_v, bsa_v, bf_use
    local sum8_v
    local conditions$, notes$

    weight = in_num("weight")
    height = in_num("height")
    neck = get_point_value("neck")
    waist = get_point_value("waist")
    hip = get_point_value("hip")
    arm_circ = get_point_value("arm_circ")
    thigh_circ = get_point_value("thigh_circ")
    calf_circ = get_point_value("calf_circ")
    biceps = get_point_value("biceps")
    triceps = get_point_value("triceps")
    subscap = get_point_value("subscapular")
    suprail = get_point_value("suprailiac")
    iliac_crest = get_point_value("iliac_crest")
    supraspinale = get_point_value("supraspinale")
    chest = get_point_value("chest")
    abdomen = get_point_value("abdomen")
    thigh_sf = get_point_value("thigh")
    midax = get_point_value("midaxillary")
    calf_sf = get_point_value("calf_medial")

    bf_dw = calc.skinfolds(sex$, biceps, triceps, subscap, suprail)
    bf_navy = calc.navy(sex$, height, neck, waist, hip)
    bf_jp7 = calc.jp7(sex$, age, chest, midax, triceps, subscap, abdomen, suprail, thigh_sf)
    
    // BF% — только для возраста 18–64 (§3.11).
    // Для <18 и 65+ все формулы BF% = -1.
    if (age < 18 or age >= 65) then
        bf_dw = -1
        bf_jp7 = -1
        bf_navy = -1
    end if
    
    sm_lee = calc.lee(sex$, age, height / 100, arm_circ, thigh_circ, calf_circ, triceps, thigh_sf, calf_sf)
    sm_janssen = calc.janssen(sex$, age, weight, height / 100)

    // Основная формула по приоритету JP7 → DW → Navy.
    if (bf_jp7 >= 0) then
        bf_use = bf_jp7
    elsif (bf_dw >= 0) then
        bf_use = bf_dw
    elsif (bf_navy >= 0) then
        bf_use = bf_navy
    else
        bf_use = -1
    end if
    bf_main = bf_use

    // ISAK Σ8 (мм). При недостатке данных — -1 (в CSV как "").
    sum8_v = calc.sum8(triceps, subscap, biceps, iliac_crest, supraspinale, abdomen, thigh_sf, calf_sf)

    if (bf_use >= 0) then
        fat_kg = weight * bf_use / 100
        lean_kg = weight - fat_kg
    else
        fat_kg = -1
        lean_kg = -1
    end if

    bmi_v = calc.bmi(weight, height)
    whr_v = calc.whr(waist, hip)
    whtr_v = calc.whtr(waist, height)
    bsa_v = calc.bsa(weight, height)

    conditions$ = in_get$("conditions_ok")
    if (conditions$ = "") conditions$ = "Y"
    notes$ = in_get$("visit_notes")

    // 37 колонок — см. VISIT_HEADER$ в store.yab.
    visit_arr$(1)  = visit_date$
    visit_arr$(2)  = fmt_num$(age)
    visit_arr$(3)  = fmt_field$("weight")
    visit_arr$(4)  = tem_status$
    visit_arr$(5)  = fmt_field$("height")
    visit_arr$(6)  = fmt_num$(neck)
    visit_arr$(7)  = fmt_num$(waist)
    visit_arr$(8)  = fmt_num$(hip)
    visit_arr$(9)  = fmt_num$(arm_circ)
    visit_arr$(10) = fmt_num$(thigh_circ)
    visit_arr$(11) = fmt_num$(calf_circ)
    visit_arr$(12) = fmt_num$(biceps)
    visit_arr$(13) = fmt_num$(triceps)
    visit_arr$(14) = fmt_num$(subscap)
    visit_arr$(15) = fmt_num$(suprail)
    visit_arr$(16) = fmt_num$(iliac_crest)
    visit_arr$(17) = fmt_num$(supraspinale)
    visit_arr$(18) = fmt_num$(chest)
    visit_arr$(19) = fmt_num$(abdomen)
    visit_arr$(20) = fmt_num$(thigh_sf)
    visit_arr$(21) = fmt_num$(midax)
    visit_arr$(22) = fmt_num$(calf_sf)
    visit_arr$(23) = fmt_num$(bf_dw)
    visit_arr$(24) = fmt_num$(bf_jp7)
    visit_arr$(25) = fmt_num$(bf_navy)
    visit_arr$(26) = fmt_num$(bf_main)
    visit_arr$(27) = fmt_num$(sm_lee)
    visit_arr$(28) = fmt_num$(sm_janssen)
    visit_arr$(29) = fmt_num$(fat_kg)
    visit_arr$(30) = fmt_num$(lean_kg)
    visit_arr$(31) = fmt_num$(bmi_v)
    visit_arr$(32) = fmt_num$(whr_v)
    visit_arr$(33) = fmt_num$(whtr_v)
    visit_arr$(34) = fmt_num$(bsa_v)
    visit_arr$(35) = conditions$
    visit_arr$(36) = notes$
    visit_arr$(37) = fmt_num$(sum8_v)

    return store.build_line$(visit_arr$(), 37)
end sub

// ============================================================
// TEM — СТАТУС
// ============================================================

// ------------------------------------------------------------
// compute_tem_status$()
// Возвращает "" / "high" / "ok" / "bad". См. PROJECT_STATE §3.12.
//   ""    — ни у одной точки нет повторов
//   bad   — ЛИБО агрегат, ЛИБО хотя бы одна точка > порога
//   high  — не bad, все складки ≤ tem_high_precision
//   ok    — не bad, не high
// ------------------------------------------------------------
sub compute_tem_status$()
    local i, k, mean, sd, rt
    local any_repeat, any_bad, sf_high_all
    local vals$(5)
    local sf_thr, circ_thr, sf_high

    sf_thr   = cfg.get_num("tem_threshold")
    circ_thr = cfg.get_num("tem_circ_threshold")
    sf_high  = cfg.get_num("tem_high_precision")

    any_repeat  = 0
    any_bad     = 0
    sf_high_all = 1

    for i = 1 to 17
        k = in_values(tem_list$(i), vals$(), 5)
        if (k >= 2) then
            any_repeat = 1
            mean = calc.tem_mean(vals$(), k)
            sd   = calc.tem_sd(vals$(), k, mean)
            rt   = calc.tem_rtem(mean, sd)

            if (tem_is_sf(i) = 1) then
                if (rt > sf_thr)  any_bad = 1
                if (rt > sf_high) sf_high_all = 0
            else
                if (rt > circ_thr) any_bad = 1
            end if
        end if
    next i

    if (any_repeat = 0) return ""
    if (any_bad = 1)    return "bad"
    if (sf_high_all = 1) return "high"
    return "ok"
end sub

// ------------------------------------------------------------
// fmt_num2$(v)
// Число с фиксированными 2 знаками. 6.0 → "6.00", 6.02 → "6.02".
// Отрицательное → "".
// ------------------------------------------------------------
sub fmt_num2$(v)
    local s$, dp
    if (v < 0) return ""
    s$ = trim$(str$(calc.round2(v)))
    dp = instr(s$, ".")
    if (dp = 0) then
        return s$ + ".00"
    end if
    if (len(s$) - dp = 1) then
        return s$ + "0"
    end if
    return s$
end sub

// ------------------------------------------------------------
// write_tem_log(id$, visit_date$)
// Запись <id>_tem.csv. Append + заголовок при создании.
// Только точки с k >= 2. 9 колонок (§3.12).
// ------------------------------------------------------------
sub write_tem_log(id$, visit_date$)
    local i, j, k, cnt, mean, sd, rt
    local vals$(5)
    local fname$, f, need_header
    local tl$(20)  // грабля 10: local с размером, сбрасывается при вызове

    fname$ = id$ + "_tem.csv"

    // Проверка существования (грабля 22)
    f = open(fname$, "r")
    if (f = 0) then
        need_header = 1
    else
        close f
        need_header = 0
    end if

    // Сбор строк
    cnt = 0
    for i = 1 to 17
        k = in_values(tem_list$(i), vals$(), 5)
        if (k >= 2) then
            mean = calc.tem_mean(vals$(), k)
            sd   = calc.tem_sd(vals$(), k, mean)
            rt   = calc.tem_rtem(mean, sd)
            cnt  = cnt + 1
            tl$(cnt) = visit_date$ + "," + tem_list$(i)
            for j = 1 to 5
                if (j <= k) then
                    tl$(cnt) = tl$(cnt) + "," + vals$(j)
                else
                    tl$(cnt) = tl$(cnt) + ","
                end if
            next j
            tl$(cnt) = tl$(cnt) + "," + fmt_num2$(mean)
            tl$(cnt) = tl$(cnt) + "," + fmt_num2$(rt)
        end if
    next i

    if (cnt = 0) return

    // Запись (append через "a", грабля 21)
    if (need_header = 1) then
        open fname$ for writing as #1
        print #1, "visit_date,point,v1,v2,v3,v4,v5,mean,tem_pct"
    else
        open #1, fname$, "a"
    end if

    for i = 1 to cnt
        print #1, tl$(i)
    next i

    close #1
end sub

// ============================================================
// ГЛАВНАЯ ПРОГРАММА
// ============================================================

// 0. Язык

// Список TEM-точек: складки (поз. 12–22), затем обхваты (поз. 6–11).
tem_list$(1)  = "biceps"
tem_is_sf(1)  = 1
tem_list$(2)  = "triceps"
tem_is_sf(2)  = 1
tem_list$(3)  = "subscapular"
tem_is_sf(3)  = 1
tem_list$(4)  = "suprailiac"
tem_is_sf(4)  = 1
tem_list$(5)  = "iliac_crest"
tem_is_sf(5)  = 1
tem_list$(6)  = "supraspinale"
tem_is_sf(6)  = 1
tem_list$(7)  = "chest"
tem_is_sf(7)  = 1
tem_list$(8)  = "abdomen"
tem_is_sf(8)  = 1
tem_list$(9)  = "thigh"
tem_is_sf(9)  = 1
tem_list$(10) = "midaxillary"
tem_is_sf(10) = 1
tem_list$(11) = "calf_medial"
tem_is_sf(11) = 1
tem_list$(12) = "neck"
tem_is_sf(12) = 0
tem_list$(13) = "waist"
tem_is_sf(13) = 0
tem_list$(14) = "hip"
tem_is_sf(14) = 0
tem_list$(15) = "arm_circ"
tem_is_sf(15) = 0
tem_list$(16) = "thigh_circ"
tem_is_sf(16) = 0
tem_list$(17) = "calf_circ"
tem_is_sf(17) = 0

n = lang.load("lang.txt")
print lang.t$("app.title")
print

// 1. Config
print lang.t$("config.loading")
n = cfg.load("config.cfg")
if (n = 0) then
    fatal(lang.t$("config.err"))
end if
print lang.t$("config.ok")
print

// 2. Input
print lang.t$("input.loading")
n = load_input("input.txt")
if (n = 0) then
    fatal(lang.t$("input.err"))
end if
print lang.t$("input.ok")
print

// 3. Validation
print lang.t$("validate.loading")
if (in_get$("last_name") = "") then
    fatal(lang.t$("validate.err_last_name"))
end if
if (in_get$("first_name") = "") then
    fatal(lang.t$("validate.err_first_name"))
end if
sex$ = lower$(in_get$("sex"))
if (sex$ <> "m" and sex$ <> "f") then
    fatal(lang.t$("validate.err_sex"))
end if
weight = in_num("weight")
height = in_num("height")
if (weight <= 0 or height <= 0) then
    fatal(lang.t$("validate.err_weight_height"))
end if
print lang.t$("validate.ok")
print

// 4. Store init
store.init("clients")

// 5. Определяем ID и клиента
id$ = in_get$("id")

if (id$ = "") then
    // Новый клиент
    id$ = store.new_id$()
    print lang.subst$("client.new_id", id$)
    print lang.t$("client.creating")

    r = store.add_client(id$, sex$, in_get$("last_name"), in_get$("first_name"), in_get$("middle_name"), in_get$("birth_date"), in_get$("phone"), in_get$("email"), in_get$("notes"))
    if (r = 0) then
        fatal_store_error()
    end if
    print lang.t$("client.created")
else
    // Существующий клиент
    print lang.subst$("app.client_id", id$)
    if (store.client_exists(id$) = 0) then
        fatal(lang.subst$("err.client_not_found", id$))
    end if
    db_sex$ = lower$(store.client_field$(id$, "sex"))
    if (db_sex$ = "m" or db_sex$ = "f") then
        sex$ = db_sex$
    end if
    ln$ = store.client_field$(id$, "last_name")
    fn$ = store.client_field$(id$, "first_name")
    print lang.subst$("client.found", ln$ + " " + fn$)
end if
print

// 6. Возраст
age = in_num("age")
if (age = 0) then
    birth$ = store.client_field$(id$, "birth_date")
    vdate$ = in_get$("visit_date")
    if (vdate$ = "") then
        dummy = split(date$, tmp_date$(), "-")
        vdate$ = tmp_date$(4) + "-" + tmp_date$(2) + "-" + tmp_date$(3)
    end if
    age = calc_age(birth$, vdate$)
end if
print lang.subst$("visit.age", trim$(str$(age)))
print

// 7. Расчёт и формирование строки визита
print lang.t$("visit.calc_loading")
vdate$ = in_get$("visit_date")
if (vdate$ = "") then
    dummy = split(date$, tmp_date$(), "-")
    v_day$ = tmp_date$(3)
    v_month$ = tmp_date$(2)
    v_year$ = tmp_date$(4)
    dummy = split(time$, tmp_date$(), "-")
    v_hour$ = tmp_date$(1)
    v_min$ = tmp_date$(2)
    if (len(v_day$) = 1) v_day$ = "0" + v_day$
    if (len(v_month$) = 1) v_month$ = "0" + v_month$
    if (len(v_hour$) = 1) v_hour$ = "0" + v_hour$
    if (len(v_min$) = 1) v_min$ = "0" + v_min$
    vdate$ = v_year$ + "-" + v_month$ + "-" + v_day$ + " " + v_hour$ + ":" + v_min$
end if

tem_status$ = compute_tem_status$()
row$ = build_visit_row$(id$, sex$, age, vdate$, tem_status$)
print lang.t$("visit.calc_ok")
print

// 8. Сохранение визита
print lang.t$("visit.save_loading")
r = store.add_visit(id$, row$)
if (r = 0) then
    fatal_store_error()
end if
print lang.t$("visit.save_ok")
print

// TEM-лог (только при наличии повторов)
write_tem_log(id$, vdate$)

print lang.t$("app.done")
print lang.subst$("app.client_id", id$)
Аватара пользователя
Anton
Site Admin
Сообщения: 254
Зарегистрирован: Чт фев 08, 2024 7:03 pm
Откуда: Москва

Генератор HTML-отчётов (Часть 1)

Сообщение Anton »

Самый большой модуль в проекте — html.yab (около 1400 строк). Его задача — превратить сырые данные из CSV в красивый отчёт для печати на A4.

В нормальном мире для этого берут шаблонизаторы вроде Jinja2 или Twig. Но у нас Yabasic 2.91.1, в котором нет даже replace$. Пришлось строить HTML-дерево руками через print #1, ....

1. Почему нельзя собирать HTML в строку?

Первое, что хочется сделать — создать переменную html$ и накидывать в неё теги через html$ = html$ + "<div>...".

Делать так категорически нельзя. Из-за того, что строки в Yabasic копируются целиком, сборка отчёта на 50 килобайт через конкатенацию займет вечность (тот самый O(n²)). Поэтому в html.yab используется только потоковый вывод: открываем файл на запись и сразу шлём в него куски через print #1.

2. Экранирование (html_escape)

Данные в базе могут содержать символы <, >, & или кавычки. Если их не экранировать, верстка поедет, а в худшем случае получится XSS (хоть это и локальный офлайн-отчет, но все же). Встроенной функции нет, поэтому пишем свой парсер символов:

Код: Выделить всё

sub html_escape$(s$)
    local i, c$, result$
    result$ = ""
    for i = 1 to len(s$)
        c$ = mid$(s$, i, 1)
        if (c$ = "&") then
            result$ = result$ + "&amp;"
        elsif (c$ = "<") then
            result$ = result$ + "&lt;"
        elsif (c$ = ">") then
            result$ = result$ + "&gt;"
        elsif (c$ = q$) then
            result$ = result$ + "&quot;"
        else
            result$ = result$ + c$
        end if
    next i
    return result$
end sub
Грабля: встроенный CSS и @page

Отчёт генерируется для печати на A4. Чтобы страница не поехала, используется @page { size: A4; margin: 10mm; }.

Но есть грабля №35: если положить @page внутрь @media print, Firefox его проигнорирует. Он должен быть на верхнем уровне CSS. Приходится следить за структурой. К тому же, чтобы сверху печатался колонтитул (running footer), нужен именно @page с margin, иначе фиксированный footer налезет на текст.

В коде это выглядит так:

Код: Выделить всё

print #1, "@page{size:A4;margin:10mm}"
print #1, "@media print{body{max-width:none;margin:0;padding:0;font-size:10pt;line-height:1.25}"
print #1, ".page-break{page-break-before:always}"
Динамические таблицы

В CSV-файле у нас 37 колонок. Но показывать пустые колонки в отчете глупо (если врач не замерял складки, колонка "DW" будет пустой).

Перед генерацией отчета мы сканируем все визиты клиента и составляем массив флагов col_has(40). Если хотя бы в одном визите есть значение — колонка попадает в отчет. Это позволяет делать таблицы компактными:

Код: Выделить всё

// Функция detect_columns() заполняет col_has() единицами 
// для тех колонок, где есть данные.

// В цикле вывода таблицы:
if (col_has(v_weight) = 1) print #1, "<th>" + lang.t$("html.weight") + " (kg)</th>"
if (col_has(v_sum8) = 1) print #1, "<th>" + lang.t$("html.isak_sum8") + " (mm)</th>"
Выжимка из кода

Ниже — каркас функции write_report и вспомогательные утилиты вывода. Полный CSS-блок и рутину вывода каждой таблицы я опустил, чтобы не раздувать пост.

Код: Выделить всё

// ... (пропущена шапка файла и константы) ...

q$ = chr$(34) // Кавычка для HTML-атрибутов

// ------------------------------------------------------------
// Экранирование HTML
// ------------------------------------------------------------
sub html_escape$(s$)
    local i, c$, result$
    result$ = ""
    for i = 1 to len(s$)
        c$ = mid$(s$, i, 1)
        if (c$ = "&") then
            result$ = result$ + "&amp;"
        elsif (c$ = "<") then
            result$ = result$ + "&lt;"
        elsif (c$ = ">") then
            result$ = result$ + "&gt;"
        elsif (c$ = q$) then
            result$ = result$ + "&quot;"
        else
            result$ = result$ + c$
        end if
    next i
    return result$
end sub

// ------------------------------------------------------------
// Вывод строки таблицы (с единицами измерения)
// ------------------------------------------------------------
sub row_value(label$, val$, unit$)
    if (val$ = "") return
    print #1, "<tr><td>" + label$ + "</td><td>" + round1_field$(val$) + " " + unit$ + "</td></tr>"
end sub

// ------------------------------------------------------------
// Встроенный CSS (если файл assets/style.css не найден)
// ------------------------------------------------------------
sub print_inline_css()
    print #1, "<style>"
    // ... (тут 50 строк стилей: table, td, th, .disclaimer, .warning и т.д.)
    print #1, "@page{size:A4;margin:10mm}"
    print #1, "@media print{body{max-width:none;margin:0;padding:0;font-size:10pt}}"
    print #1, ".footer{position:fixed;bottom:0;left:0;right:0;background:white;text-align:center}"
    print #1, "</style>"
end sub

// ------------------------------------------------------------
// Генерация отчёта
// ------------------------------------------------------------
sub write_report(id$, mode$)
    local n, i, fname$, sex$, fullname$
    
    // ... (чтение данных из store) ...
    
    fname$ = id$ + ".html"
    open fname$ for writing as #1
    
    // Шапка
    print #1, "<!DOCTYPE html>"
    print #1, "<html lang=" + q$ + "ru" + q$ + "><head>"
    print #1, "<meta charset=" + q$ + "utf-8" + q$ + ">"
    
    // Подключение CSS
    if (file_exists("assets/style.css")) then
        print #1, "<link rel=" + q$ + "stylesheet" + q$ + " href=" + q$ + "assets/style.css" + q$ + ">"
    else
        print_inline_css()
    end if
    print #1, "</head><body>"
    
    // Инфо о клиенте
    print #1, "<h1>" + lang.t$("html.title") + "</h1>"
    print #1, "<dl class=" + q$ + "info" + q$ + ">"
    print #1, "<dt>" + lang.t$("html.name") + "</dt><dd>" + html_escape$(fullname$) + "</dd>"
    print #1, "</dl>"
    
    // Последнее измерение
    print #1, "<h2>" + lang.t$("html.latest") + "</h2>"
    store.parse_row(visit_lines$(n))
    
    print #1, "<table class=" + q$ + "measure-table" + q$ + ">"
    row_value(lang.t$("html.weight"), store.row_field$(v_weight), "kg")
    row_value(lang.t$("html.height"), store.row_field$(v_height), "cm")
    print #1, "</table>"
    
    // ... (тут блоки ISAK, Оценка жира, Массы, Справочные) ...
    
    print #1, "</body></html>"
    close #1
    return 1
end sub
В следующем посте — вторая часть html.yab: ручная отрисовка SVG-графиков и оглавление.
Аватара пользователя
Anton
Site Admin
Сообщения: 254
Зарегистрирован: Чт фев 08, 2024 7:03 pm
Откуда: Москва

Генератор HTML-отчётов (Часть 2: Графики)

Сообщение Anton »

Во второй части разговора про html.yab — графики. Отчёт для врача, где нет динамики изменения веса или процента жира, считается неполноценным. Но тащить в Yabasic библиотеку для генерации графиков (даже если бы она существовала) — это оверхед. Поэтому я решил рисовать SVG-графики руками.

SVG — это просто текст. Обычные теги <line>, <circle>, <polyline>. Программа просто считает координаты и печатает их через print #1.

1. Грабля: «Красивые» оси

Если взять минимальное и максимальное значение и просто разделить отрезок на 5 частей, получатся подписи вроде 13.74, 27.48 и 41.22. Читать такое неудобно. Нужны «круглые» значения (10, 20, 30).

Для этого я написал функцию nice_step(x). Она берет сырой диапазон, пускает его в цикл и подбирает ближайший удобный шаг из ряда: 0.1, 0.2, 0.25, 0.5, 1, 2, 2.5, 5, 10, 20...

Код: Выделить всё

sub nice_step(x)
    local p, r
    if (x <= 0) return 1
    p = 1
    while (x / p >= 10)
        p = p * 10
    wend
    while (x / p < 1)
        p = p / 10
    wend
    r = x / p
    if (r <= 1)   return 1 * p
    if (r <= 2)   return 2 * p
    if (r <= 2.5) return 2.5 * p
    if (r <= 5)   return 5 * p
    return 10 * p
end sub
А функция calc_axis() использует этот шаг, чтобы найти нижнюю и верхнюю границы оси Y (глобальные переменные ax_min, ax_max).

2. Грабля: наезжающие подписи

Если у клиента 30 визитов, подписывать каждую точку на оси X — безумие. Подписи слипнутся. Нужно их прореживать. При этом важно, чтобы первая и последняя точки всегда были подписаны.

Я написал label_skip(plot_w, n). Она считает, через сколько точек ставить подпись, чтобы расстояние между ними было не меньше 50 пикселей.

В самом цикле вывода проверка выглядит монструозно, но работает безотказно:

Код: Выделить всё

if ((i = 1) or (i = n) or (mod(i - 1, lstep) = 0 and (n - i) >= lstep)) then
    // выводим подпись
end if
3. Координаты только относительные

Это грабля из категории «выстрелит потом». Если написать в коде plot_y - 18, график привяжется к конкретному размеру окна. Стоит поменять height у тега <svg> — и всё съедет.

Поэтому в draw_chart_single все отступы считаются от базовых переменных plot_x, plot_y, plot_w, plot_h. Даже если изменить размер холста, график останется ровным.

4. Stacked Area Chart

Помимо обычных линий, в отчете есть график состава тела (жир + сухая масса). Сложность в том, что это не просто две линии, а две области, которые накладываются друг на друга.

Рисуется это через два тега <polygon>. Сначала строится верхняя граница сухой массы, потом она замыкается по нижнему краю. Затем строится верхняя граница общего веса и замыкается по линии сухой массы.

Код: Выделить всё

// Упрощенная логика сборки координат для stacked area
poly_lean$ = lean_top$ + n2s$(x_last) + "," + n2s$(baseline) + " " + n2s$(x_first) + "," + n2s$(baseline)
poly_fat$ = total_top$ + lean_rev$
Выжимка из кода (Отрисовка линейного графика)

Код: Выделить всё

// ------------------------------------------------------------
// Отрисовка линейного графика
// ------------------------------------------------------------
sub draw_chart_single(title$, vals$(), n, unit$, color$)
    local i, has_data, min_v, max_v, range, v
    local plot_x, plot_y, plot_w, plot_h, x_step, x, y
    local points$, lstep

    // 1. Поиск минимума и максимума
    has_data = 0
    min_v = 999999
    max_v = -999999
    for i = 1 to n
        if (vals$(i) <> "") then
            v = val(vals$(i))
            if (v < min_v) min_v = v
            if (v > max_v) max_v = v
            has_data = 1
        end if
    next i
    if (has_data = 0) return

    // 2. Расчет красивых осей
    calc_axis(min_v, max_v)
    min_v = ax_min
    max_v = ax_max
    range = max_v - min_v

    // 3. Базовые координаты (относительные)
    plot_x = 55
    plot_y = 20
    plot_w = 625
    plot_h = 170

    print #1, "<div class=" + q$ + "chart" + q$ + ">"
    print #1, "<div class=" + q$ + "chart-title" + q$ + ">" + title$ + "</div>"
    print #1, "<svg width=" + q$ + "700" + q$ + " height=" + q$ + "220" + q$ + " viewBox=" + q$ + "0 0 700 220" + q$ + " xmlns=" + q$ + "http://www.w3.org/2000/svg" + q$ + ">"

    // 4. Сетка и подписи оси Y
    for i = 0 to ax_nseg
        y = plot_y + int(plot_h * i / ax_nseg)
        print #1, "<line x1=" + q$ + n2s$(plot_x) + q$ + " y1=" + q$ + n2s$(y) + q$ + " x2=" + q$ + n2s$(plot_x + plot_w) + q$ + " y2=" + q$ + n2s$(y) + q$ + " stroke=" + q$ + "#eee" + q$ + "/>"
        print #1, "<text x=" + q$ + n2s$(plot_x - 5) + q$ + " y=" + q$ + n2s$(y + 4) + q$ + " text-anchor=" + q$ + "end" + q$ + " font-size=" + q$ + "10" + q$ + " fill=" + q$ + "#666" + q$ + ">" + round1_field$(str$(max_v - ax_gstep * i)) + "</text>"
    next i

    // 5. Оси
    print #1, "<line x1=" + q$ + n2s$(plot_x) + q$ + " y1=" + q$ + n2s$(plot_y) + q$ + " x2=" + q$ + n2s$(plot_x) + q$ + " y2=" + q$ + n2s$(plot_y + plot_h) + q$ + " stroke=" + q$ + "#333" + q$ + "/>"
    print #1, "<line x1=" + q$ + n2s$(plot_x) + q$ + " y1=" + q$ + n2s$(plot_y + plot_h) + q$ + " x2=" + q$ + n2s$(plot_x + plot_w) + q$ + " y2=" + q$ + n2s$(plot_y + plot_h) + q$ + " stroke=" + q$ + "#333" + q$ + "/>"

    // 6. Прореживание и отрисовка точек
    if (n > 1) then
        x_step = plot_w / (n - 1)
    else
        x_step = 0
    end if
    lstep = label_skip(plot_w, n)
    points$ = ""

    for i = 1 to n
        if (vals$(i) <> "") then
            v = val(vals$(i))
            if (n > 1) then
                x = plot_x + int(x_step * (i - 1))
            else
                x = plot_x + int(plot_w / 2)
            end if
            y = plot_y + plot_h - int((v - min_v) / range * plot_h)
            points$ = points$ + n2s$(x) + "," + n2s$(y) + " "
        end if
    next i

    if (points$ <> "") then
        print #1, "<polyline points=" + q$ + points$ + q$ + " fill=" + q$ + "none" + q$ + " stroke=" + q$ + color$ + q$ + " stroke-width=" + q$ + "2" + q$ + "/>"
    end if

    // Вывод кружков и подписей (пропущено для краткости, логика та же)
    
    print #1, "</svg>"
    print #1, "</div>"
end sub
Аватара пользователя
Anton
Site Admin
Сообщения: 254
Зарегистрирован: Чт фев 08, 2024 7:03 pm
Откуда: Москва

Грабли Yabasic, на которые я наступил

Сообщение Anton »

За время работы над проектом я собрал коллекцию особенностей Yabasic, которые сбивают с толку (особенно если приходишь из языков вроде Python или C). Часть из них я уже упоминал в предыдущих постах, часть — нет. Вот сводный список с кодом «как падает / как лечить».

Синтаксис и типы

1. for i = n to 1 молча не выполняется
Самая коварная грабля. Если написать for i = 10 to 1, цикл просто не выполнится ни разу. Yabasic не ругается, не выдает ошибку — просто пропускает блок.
Лечение: всегда писать for i = n to 1 step -1.

2. dim внутри sub течёт между вызовами
Если объявить массив внутри подпрограммы через dim, он создается один раз, а потом сохраняет значения между вызовами. Для локальных переменных это катастрофа (данные клиента A утекут в отчет клиента B).
Лечение: внутри sub использовать только local a$(10). Причем размер обязателен: local a$() без размера выдаст runtime error.

3. mod — это функция, а не оператор
В C или Python можно написать a % b или a mod b. В Yabasic mod — это встроенная функция.
Лечение: mod(a, b), а не a mod b.

4. val("abc") возвращает 0
Если попытаться преобразовать строку в число и она не является числом, val() вернет 0. Приходится писать свою функцию is_number(), чтобы отличить реальный ноль от ошибки.

5. str$(n) для больших чисел дает экспоненту
Функция str$(1789250000) вернет "1.78925e+09". Если вы используете это для имени файла, программа упадет.
Лечение: своя функция int_to_str$() через деление и остаток.

Строки и производительность

6. Конкатенация через + — это O(n²)
s$ = s$ + "x" в цикле копирует всю строку целиком. На 100 000 итераций это занимает около 3.3 секунд (а может и больше, я не помню).
Лечение: использовать string$(N, "x") (мгновенно) или собирать данные через print #1 (построчная запись в файл).

7. replace$ отсутствует в 2.91.1
В мануалах пишут, что replace$() есть, но в версии 2.91.1 она выдает ошибку. Появилась только в 2.92.0.
Лечение: своя функция через instr и mid$ (заменяет только первое вхождение). Ну или использовать актуальную версию, я выбрал ту что была в Debian (Devuan) по дефолту ради совместимости с прошлыми версиями yabasic.

8. lvalue mid$ / left$ / right$ копируют всю строку
Запись mid$(s$, i, 1) = "x" в цикле на 100 000 итераций занимает 5.4 секунд.
Лечение: использовать только на коротких строках (< 1 КБ) или собирать строку через чанки.

Дата и время

9. time$ — десятые доли, а не миллисекунды
Формат time$ — HH-MM-SS-D, где D — это десятые доли секунды (0-9). Разрешение — 100 ms. Точнее измерить нельзя.
Лечение: для точных бенчмарков использовать внешний bash (date +%s%N). В коде помнить, что нельзя получить миллисекунды.

10. date$ — 6 полей через дефис
Формат DOW-MM-DD-YYYY-DayName-MonthName. Приходится разбивать через split() и брать нужные индексы (месяц — 2, день — 3, год — 4). Никаких strftime().

Импорты и namespaces

11. Не использовать export sub
Экспорт делает функцию глобальной, что приводит к конфликту имен при импорте двух модулей с одинаковыми функциями (например, load()).
Лечение: только sub, обращение через namespace (cfg.load()).

12. Двойной импорт запрещен
Если store.yab импортирует csv.yab, а главный скрипт импортирует и store, и csv — программа упадет с ошибкой duplicate subroutine. Yabasic не дедуплицирует импорты.
Лечение: строгая иерархия зависимостей. Обертки в store для доступа к функциям csv.

13. break в импортированных модулях выходит из подпрограммы
Если использовать break внутри цикла в импортированном модуле, он вылетает не из цикла, а из всей подпрограммы.
Лечение: использовать флаги (done = 1), а не break.

Файловый ввод-вывод

14. line input #1, line$ — ошибка
Запятая после файлового дескриптора ломает парсер. Правильно: line input #1 line$.

15. open ... for reading падает, если файла нет
Конструкция open fname$ for reading as #1 вызывает фатальную ошибку, если файл не существует.
Лечение: проверка через f = open(fname$, "r"), которая возвращает 0, если файла нет.

16. Нет режима for append
Если нужно дописать в конец файла, нельзя использовать open ... for append. Только open #1, fname$, "a".

17. line input не понимает многострочные поля
Читает до первого \n. Если в поле notes есть перенос строки, вторая часть молча теряется. Парсер CSV тут не поможет.