380 lines
13 KiB
PHP
380 lines
13 KiB
PHP
<?php
|
||
/**
|
||
* AegisOne Engineering — Функции управления документацией в Markdown
|
||
*
|
||
* Обеспечивает:
|
||
* - Автоматическое обнаружение .md-файлов в service/docs/
|
||
* - Разграничение доступа по ролям (owner — всё, engineer — только разрешённое)
|
||
* - Хранение прав в JSON-файле service/permissions/docs_permissions.json
|
||
*/
|
||
|
||
define('DOCS_DIR', __DIR__ . '/../docs');
|
||
define('DOCS_PERMISSIONS_FILE', __DIR__ . '/../permissions/docs_permissions.json');
|
||
|
||
/**
|
||
* Сканирует папку docs/ и возвращает массив документов.
|
||
* Автоматически добавляет новые файлы в файл прав (если их там нет).
|
||
*
|
||
* @return array Массив документов: [['slug'=>'...', 'title'=>'...', 'filename'=>'...'], ...]
|
||
*/
|
||
function get_docs_list(): array
|
||
{
|
||
if (!is_dir(DOCS_DIR)) {
|
||
return [];
|
||
}
|
||
|
||
$files = glob(DOCS_DIR . '/*.md');
|
||
if ($files === false || empty($files)) {
|
||
return [];
|
||
}
|
||
|
||
$docs = [];
|
||
$permissions = load_permissions();
|
||
|
||
foreach ($files as $path) {
|
||
$filename = basename($path);
|
||
$slug = doc_slug($filename);
|
||
$title = doc_title($slug, $filename);
|
||
|
||
// Автоматически добавляем новый документ в права, если его ещё нет
|
||
if (!isset($permissions[$slug])) {
|
||
$permissions[$slug] = [
|
||
'title' => $title,
|
||
'filename' => $filename,
|
||
'sort_order' => count($permissions),
|
||
'engineer' => false,
|
||
];
|
||
}
|
||
|
||
// Миграция: старый boolean → новый array permissions
|
||
if (isset($permissions[$slug]['engineer']) && !isset($permissions[$slug]['permissions'])) {
|
||
$permissions[$slug]['permissions'] = [
|
||
'engineer' => [
|
||
'view' => !empty($permissions[$slug]['engineer']),
|
||
'edit' => false,
|
||
'cancel' => false,
|
||
]
|
||
];
|
||
}
|
||
|
||
// Если файл на диске отличается от сохранённого — обновляем
|
||
if ($permissions[$slug]['filename'] !== $filename) {
|
||
$permissions[$slug]['filename'] = $filename;
|
||
}
|
||
|
||
$docs[] = [
|
||
'slug' => $slug,
|
||
'title' => $permissions[$slug]['title'],
|
||
'filename' => $filename,
|
||
'sort_order' => $permissions[$slug]['sort_order'] ?? 0,
|
||
];
|
||
}
|
||
|
||
// Сохраняем, если были добавлены новые
|
||
save_permissions($permissions);
|
||
|
||
// Сортировка по sort_order (ручная), затем по title
|
||
usort($docs, function($a, $b) {
|
||
$soA = $a['sort_order'] ?? 0;
|
||
$soB = $b['sort_order'] ?? 0;
|
||
if ($soA !== $soB) return $soA - $soB;
|
||
return strcmp($a['title'], $b['title']);
|
||
});
|
||
|
||
return $docs;
|
||
}
|
||
|
||
/**
|
||
* Возвращает содержимое .md-файла по слагу.
|
||
* Ищет файл по slug, сканируя директорию (не полагается на сохранённое имя).
|
||
*
|
||
* @param string $slug Слаг документа
|
||
* @return string|null Содержимое файла или null, если не найден
|
||
*/
|
||
function get_doc_content(string $slug): ?string
|
||
{
|
||
$permissions = load_permissions();
|
||
if (!isset($permissions[$slug])) {
|
||
return null;
|
||
}
|
||
|
||
// Сначала пробуем сохранённое имя файла
|
||
$savedFilename = $permissions[$slug]['filename'];
|
||
$path = DOCS_DIR . '/' . $savedFilename;
|
||
if (file_exists($path)) {
|
||
$content = file_get_contents($path);
|
||
return $content !== false ? $content : null;
|
||
}
|
||
|
||
// Если не нашли — ищем по slug среди всех .md файлов
|
||
$files = glob(DOCS_DIR . '/*.md');
|
||
if ($files === false) return null;
|
||
|
||
foreach ($files as $filePath) {
|
||
$fn = basename($filePath);
|
||
$fSlug = doc_slug($fn);
|
||
if ($fSlug === $slug) {
|
||
// Обновляем сохранённое имя
|
||
$permissions[$slug]['filename'] = $fn;
|
||
save_permissions($permissions);
|
||
$content = file_get_contents($filePath);
|
||
return $content !== false ? $content : null;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Преобразует md-заголовки и ссылки в HTML.
|
||
* Простейший парсер для базовой читаемости.
|
||
*
|
||
* @param string $markdown Исходный Markdown
|
||
* @return string HTML
|
||
*/
|
||
function render_markdown_simple(string $markdown): string
|
||
{
|
||
$html = htmlspecialchars($markdown, ENT_NOQUOTES, 'UTF-8');
|
||
|
||
// Заголовки h1-h3
|
||
$html = preg_replace('/^### (.+)$/m', '<h3>$1</h3>', $html);
|
||
$html = preg_replace('/^## (.+)$/m', '<h2>$1</h2>', $html);
|
||
$html = preg_replace('/^# (.+)$/m', '<h1>$1</h1>', $html);
|
||
|
||
// Жирный и курсив
|
||
$html = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $html);
|
||
$html = preg_replace('/\*(.+?)\*/', '<em>$1</em>', $html);
|
||
|
||
// Списки
|
||
$html = preg_replace('/^- (.+)$/m', '<li>$1</li>', $html);
|
||
$html = preg_replace('/(<li>.*<\/li>)/s', '<ul>$1</ul>', $html);
|
||
|
||
// Таблицы — группируем строки с | в блоки <table>
|
||
$lines = explode("\n", $html);
|
||
$in_table = false;
|
||
$sep_skipped = false;
|
||
$table_lines = [];
|
||
foreach ($lines as $line) {
|
||
$trimmed = trim($line);
|
||
if (preg_match('/^\|.+\|$/', $trimmed)) {
|
||
if (!$in_table) {
|
||
$table_lines[] = '<table>';
|
||
$in_table = true;
|
||
$sep_skipped = false;
|
||
}
|
||
if (preg_match('/^\|[-:\s]+\|$/', $trimmed)) {
|
||
$sep_skipped = true;
|
||
continue;
|
||
}
|
||
$inner = substr($trimmed, 1, -1);
|
||
$cells = array_map('trim', explode('|', $inner));
|
||
$tag = $sep_skipped ? 'th' : 'td';
|
||
$sep_skipped = false;
|
||
$table_lines[] = "<tr><$tag>" . implode("</$tag><$tag>", $cells) . "</$tag></tr>";
|
||
} else {
|
||
if ($in_table) {
|
||
$table_lines[] = '</table>';
|
||
$in_table = false;
|
||
}
|
||
$sep_skipped = false;
|
||
$table_lines[] = $line;
|
||
}
|
||
}
|
||
if ($in_table) {
|
||
$table_lines[] = '</table>';
|
||
}
|
||
$html = implode("\n", $table_lines);
|
||
|
||
// Ссылки
|
||
$html = preg_replace('/\[(.+?)\]\((.+?)\)/', '<a href="$2" target="_blank">$1</a>', $html);
|
||
|
||
// Абзацы
|
||
$paragraphs = explode("\n\n", $html);
|
||
$html = '';
|
||
foreach ($paragraphs as $p) {
|
||
$p = trim($p);
|
||
if ($p === '') continue;
|
||
if (strpos($p, '<h') === 0 || strpos($p, '<ul') === 0 || strpos($p, '<tr') === 0 || strpos($p, '<th') === 0 || strpos($p, '<table') === 0) {
|
||
$html .= $p . "\n";
|
||
} elseif (strpos($p, '<li') === 0) {
|
||
$html .= $p . "\n";
|
||
} else {
|
||
$html .= '<p>' . nl2br($p) . "</p>\n";
|
||
}
|
||
}
|
||
|
||
return $html;
|
||
}
|
||
|
||
/**
|
||
* Возвращает slug из имени файла (архитектура.md → arkhitektura).
|
||
*/
|
||
function doc_slug(string $filename): string
|
||
{
|
||
$slug = pathinfo($filename, PATHINFO_FILENAME);
|
||
// Транслитерация кириллицы в латиницу для URL
|
||
$converted = transliterate($slug);
|
||
return preg_replace('/[^a-z0-9_-]/', '', strtolower($converted));
|
||
}
|
||
|
||
/**
|
||
* Транслитерация кириллицы → латиница.
|
||
*/
|
||
function transliterate(string $text): string
|
||
{
|
||
$map = [
|
||
'а'=>'a','б'=>'b','в'=>'v','г'=>'g','д'=>'d','е'=>'e','ё'=>'yo','ж'=>'zh',
|
||
'з'=>'z','и'=>'i','й'=>'y','к'=>'k','л'=>'l','м'=>'m','н'=>'n','о'=>'o',
|
||
'п'=>'p','р'=>'r','с'=>'s','т'=>'t','у'=>'u','ф'=>'f','х'=>'kh','ц'=>'ts',
|
||
'ч'=>'ch','ш'=>'sh','щ'=>'shch','ъ'=>'','ы'=>'y','ь'=>'','э'=>'e','ю'=>'yu',
|
||
'я'=>'ya','А'=>'A','Б'=>'B','В'=>'V','Г'=>'G','Д'=>'D','Е'=>'E','Ё'=>'Yo',
|
||
'Ж'=>'Zh','З'=>'Z','И'=>'I','Й'=>'Y','К'=>'K','Л'=>'L','М'=>'M','Н'=>'N',
|
||
'О'=>'O','П'=>'P','Р'=>'R','С'=>'S','Т'=>'T','У'=>'U','Ф'=>'F','Х'=>'Kh',
|
||
'Ц'=>'Ts','Ч'=>'Ch','Ш'=>'Sh','Щ'=>'Shch','Ъ'=>'','Ы'=>'Y','Ь'=>'','Э'=>'E',
|
||
'Ю'=>'Yu','Я'=>'Ya',' '=>'-','_'=>'-',
|
||
];
|
||
return strtr($text, $map);
|
||
}
|
||
|
||
/**
|
||
* Формирует человекочитаемый заголовок из слага.
|
||
* architecture → Архитектура
|
||
*/
|
||
function doc_title(string $slug, string $filename = ''): string
|
||
{
|
||
// обратная транслитерация для некоторых известных слагов
|
||
$known = [
|
||
'owner' => 'Owner',
|
||
'seo' => 'SEO',
|
||
'sla' => 'SLA',
|
||
'kpi-inzhenerov' => 'KPI инженеров',
|
||
'avtomaticheskaya-sistema-shs' => 'Автоматическая система SHS',
|
||
'oprosnik-raschet' => 'Опросник-расчёт',
|
||
'arkhitektura-kompanii' => 'Архитектура компании',
|
||
'kontent-plan' => 'Контент план',
|
||
'kp-i-struktura-audita' => 'КП и структура аудита',
|
||
'utp-i-kp' => 'УТП и КП',
|
||
'arkhetiktura-brenda' => 'Архетиктура бренда',
|
||
'marketing' => 'Маркеттинг',
|
||
'skripty-prodazh' => 'Скрипты продаж',
|
||
'osnova' => 'Основа',
|
||
'dokazatelnaya-ekspertiza' => 'Доказательная экспертиза',
|
||
'voronki-telegram-i-crm' => 'Воронки телеграм и CRM',
|
||
'voronka' => 'Воронка',
|
||
'sayt-i-upakovka' => 'Сайт и упаковка',
|
||
'dashboard-tekhnika' => 'Дашборд техника',
|
||
'dashboard-inzhenera' => 'Дашборд инженера',
|
||
'blog' => 'Блог',
|
||
'tsifrovaya-model-obshchaya' => 'Цифровая модель общая',
|
||
];
|
||
|
||
if (isset($known[$slug])) {
|
||
return $known[$slug];
|
||
}
|
||
|
||
// Если есть имя файла — используем его без .md
|
||
if ($filename !== '') {
|
||
return pathinfo($filename, PATHINFO_FILENAME);
|
||
}
|
||
|
||
return strtr($slug, ['-' => ' ', '_' => ' ']);
|
||
}
|
||
|
||
/**
|
||
* Загружает JSON-файл прав доступа.
|
||
*
|
||
* @return array
|
||
*/
|
||
function load_permissions(): array
|
||
{
|
||
if (!file_exists(DOCS_PERMISSIONS_FILE)) {
|
||
return [];
|
||
}
|
||
|
||
$json = file_get_contents(DOCS_PERMISSIONS_FILE);
|
||
$data = json_decode($json, true);
|
||
|
||
return is_array($data) ? $data : [];
|
||
}
|
||
|
||
/**
|
||
* Сохраняет JSON-файл прав доступа.
|
||
*
|
||
* @param array $permissions
|
||
* @return bool
|
||
*/
|
||
function save_permissions(array $permissions): bool
|
||
{
|
||
$dir = dirname(DOCS_PERMISSIONS_FILE);
|
||
if (!is_dir($dir)) {
|
||
mkdir($dir, 0755, true);
|
||
}
|
||
|
||
return file_put_contents(
|
||
DOCS_PERMISSIONS_FILE,
|
||
json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
) !== false;
|
||
}
|
||
|
||
/**
|
||
* Проверяет, разрешён ли документ для роли engineer.
|
||
*
|
||
* @param string $slug Слаг документа
|
||
* @return bool
|
||
*/
|
||
function is_doc_allowed_for_engineer(string $slug): bool
|
||
{
|
||
$permissions = load_permissions();
|
||
if (!isset($permissions[$slug])) return false;
|
||
$p = $permissions[$slug];
|
||
// Поддержка старого формата (boolean) и нового (array)
|
||
if (is_array($p) && isset($p['permissions'])) {
|
||
return !empty($p['permissions']['engineer']['view']);
|
||
}
|
||
return !empty($p['engineer']);
|
||
}
|
||
|
||
/**
|
||
* Проверяет конкретное право инженера для документа.
|
||
*
|
||
* @param string $slug Слаг документа
|
||
* @param string $action Действие: view, edit, cancel
|
||
* @return bool
|
||
*/
|
||
function is_engineer_allowed(string $slug, string $action): bool
|
||
{
|
||
$permissions = load_permissions();
|
||
if (!isset($permissions[$slug])) return false;
|
||
$p = $permissions[$slug];
|
||
if (is_array($p) && isset($p['permissions']['engineer'])) {
|
||
return !empty($p['permissions']['engineer'][$action]);
|
||
}
|
||
// Backward compat
|
||
if ($action === 'view') return !empty($p['engineer']);
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Возвращает список документов, доступных для указанной роли.
|
||
*
|
||
* @param string $role Роль пользователя (owner|engineer|technician)
|
||
* @return array
|
||
*/
|
||
function get_allowed_docs(string $role): array
|
||
{
|
||
$all = get_docs_list();
|
||
|
||
if ($role === 'owner') {
|
||
return $all;
|
||
}
|
||
|
||
if ($role === 'engineer') {
|
||
return array_values(array_filter($all, function($d) {
|
||
return is_doc_allowed_for_engineer($d['slug']);
|
||
}));
|
||
}
|
||
|
||
// technician — нет доступа к документам
|
||
return [];
|
||
}
|