Ground-Zerro / release Public
Code Issues Pull requests Actions Releases View on GitHub ↗
7.7 KB javascript
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const { execSync } = require('child_process');
const os = require('os');

const GITHUB_USER = process.env.GITHUB_REPOSITORY?.split('/')[0] || 'ground-zerro';
const GITHUB_REPO = process.env.GITHUB_REPOSITORY?.split('/')[1] || 'release';
const repoBaseUrl = `https://${GITHUB_USER.toLowerCase()}.github.io/${GITHUB_REPO}`;
const rootDirs = ['keenetic'];
const isGitHubCI = process.env.GITHUB_ACTIONS === 'true';
const repoRoot = isGitHubCI ? path.resolve(process.cwd()) : __dirname;

const embeddedCSS = `
body { font-family: sans-serif; background: #f8f8f8; color: #222; padding: 20px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }
th { background: #f0f0f0; cursor: pointer; }
.search { margin: 10px 0; padding: 5px; width: 200px; }
a { color: #0366d6; text-decoration: none; }
a:hover { text-decoration: underline; }
`;

function compareVersions(v1, v2) {
  const normalize = v => v.replace(/[^0-9]/g, '').padStart(8, '0');
  return parseInt(normalize(v1)) - parseInt(normalize(v2));
}

function formatSize(bytes) {
  const units = ['B', 'KB', 'MB', 'GB'];
  let i = 0;
  while (bytes >= 1024 && i < units.length - 1) {
    bytes /= 1024;
    i++;
  }
  return `${bytes.toFixed(1)} ${units[i]}`;
}

function parseControlFields(content) {
  const result = {};
  content.split('\n').forEach(line => {
    const [key, ...rest] = line.split(':');
    if (key && rest.length) {
      result[key.trim()] = rest.join(':').trim();
    }
  });
  return result;
}

function extractControlFromIpk(ipkPath) {
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ipk-'));
  try {
    const controlTar = execSync(`tar -xOf "${ipkPath}" ./control.tar.gz 2>/dev/null || tar -xOf "${ipkPath}" control.tar.gz`);
    fs.writeFileSync(path.join(tmpDir, 'control.tar.gz'), controlTar);
    execSync(`tar -xzf control.tar.gz`, { cwd: tmpDir });
    const controlPath = fs.existsSync(path.join(tmpDir, 'control'))
      ? path.join(tmpDir, 'control')
      : path.join(tmpDir, './control');
    const controlContent = fs.readFileSync(controlPath).toString();
    return parseControlFields(controlContent);
  } catch (e) {
    console.error(`⚠️ Failed to parse .ipk: ${ipkPath}`);
    return null;
  } finally {
    fs.rmSync(tmpDir, { recursive: true, force: true });
  }
}

function generatePackagesFiles(dir, relPath) {
  const entries = fs.readdirSync(dir);
  const ipkFiles = entries.filter(f => f.endsWith('.ipk'));
  if (ipkFiles.length === 0) return;

  const packageEntries = [];

  for (const file of ipkFiles) {
    const ipkPath = path.join(dir, file);
    const control = extractControlFromIpk(ipkPath);
    if (!control || !control.Package || !control.Version || !control.Architecture) continue;

    packageEntries.push({
      name: control.Package,
      version: control.Version,
      arch: control.Architecture,
      filename: file,
      size: fs.statSync(ipkPath).size,
      control
    });
  }

  // Выбор только самых новых версий
  const latestMap = {};
  for (const entry of packageEntries) {
    const key = `${entry.name}_${entry.arch}`;
    if (!latestMap[key] || compareVersions(entry.version, latestMap[key].version) > 0) {
      latestMap[key] = entry;
    }
  }

  const finalPackages = Object.values(latestMap);
  const packages = [];
  for (const entry of finalPackages) {
    const control = entry.control;
    const block = [
      `Package: ${control.Package}`,
      `Version: ${control.Version}`,
      `Depends: ${control.Depends || ''}`,
      `Section: ${control.Section || 'net'}`,
      `Architecture: ${control.Architecture}`,
      `Installed-Size: ${control['Installed-Size'] || '0'}`,
      `Filename: ${entry.filename}`,
      `Size: ${entry.size}`,
      `Maintainer: ${control.Maintainer || 'unknown'}`,
      `Description: ${control.Description || ''}`,
      ''
    ].join('\n');
    packages.push(block);
  }

  const allText = packages.join('\n');
  fs.writeFileSync(path.join(dir, 'Packages'), allText);
  fs.writeFileSync(path.join(dir, 'Packages.gz'), zlib.gzipSync(allText));

  let html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Packages in /${relPath}</title>
<style>${embeddedCSS}</style>
</head>
<body>
<h1>Packages in /${relPath}</h1>
<table>
<thead><tr><th>Name</th><th>Version</th><th>Section</th><th>Description</th></tr></thead>
<tbody>`;
  for (const block of packages) {
    const lines = block.split('\n');
    const data = {};
    lines.forEach(line => {
      const [key, ...rest] = line.split(':');
      if (key && rest.length) {
        data[key.trim()] = rest.join(':').trim();
      }
    });
    html += `<tr><td><a href="${data.Filename}">${data.Package}</a></td><td>${data.Version}</td><td>${data.Section || ''}</td><td>${data.Description || ''}</td></tr>`;
  }
  html += '</tbody></table></body></html>';
  fs.writeFileSync(path.join(dir, 'Packages.html'), html);
}

function generateIndexForDir(currentPath, rootDirAbs, rootDirRel) {
  generatePackagesFiles(currentPath, path.posix.join(rootDirRel, path.relative(rootDirAbs, currentPath).replace(/\\/g, '/')));

  const entries = fs.readdirSync(currentPath, { withFileTypes: true });
  const relativePathFromRoot = path.relative(rootDirAbs, currentPath).replace(/\\/g, '/');
  const fullPathFromRepo = path.posix.join(rootDirRel, relativePathFromRoot);
  const folderUrl = `/${fullPathFromRepo}/`.replace(/\/+/g, '/');
  const baseHref = `${repoBaseUrl}/${fullPathFromRepo}/`.replace(/\\+/g, '/').replace(/([^:]\/)\/+/g, '$1');

  const files = entries.filter(e => e.isFile() && e.name !== 'index.html')
    .map(e => ({ name: e.name, size: formatSize(fs.statSync(path.join(currentPath, e.name)).size) }))
    .sort((a, b) => a.name.localeCompare(b.name));

  const dirs = entries.filter(e => e.isDirectory())
    .map(e => ({ name: e.name + '/', size: '-' }))
    .sort((a, b) => a.name.localeCompare(b.name));

  const parentPath = fullPathFromRepo.split('/').slice(0, -1).join('/');
  const parentUrl = parentPath ? `${repoBaseUrl}/${parentPath}/` : `${repoBaseUrl}/`;

  const rows = [
    { name: '..', size: '', href: parentUrl },
    ...dirs.map(d => ({ ...d, href: `${baseHref}${encodeURI(d.name)}` })),
    ...files.map(f => ({ ...f, href: `${baseHref}${encodeURI(f.name)}` }))
  ];

  let html = `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<title>Index of ${folderUrl}</title>
<style>
body { font-family: monospace; padding: 2em; background: #fafafa; color: #333; }
table { width: 100%; max-width: 800px; border-collapse: collapse; }
td { padding: 0.3em 0.6em; border-bottom: 1px solid #ddd; }
td.size { text-align: right; color: #666; white-space: nowrap; }
a { color: #0366d6; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { margin-bottom: 1em; }
</style></head><body>
<h1>Index of ${folderUrl}</h1>
<table>`;
  for (const row of rows) {
    html += `<tr><td><a href="${row.href}">${row.name}</a></td><td class="size">${row.size}</td></tr>\n`;
  }
  html += '</table></body></html>';
  fs.writeFileSync(path.join(currentPath, 'index.html'), html, 'utf-8');
}

function walkAndGenerate(currentDir, rootDirAbs, rootDirRel) {
  generateIndexForDir(currentDir, rootDirAbs, rootDirRel);
  const entries = fs.readdirSync(currentDir, { withFileTypes: true });
  for (const entry of entries) {
    if (entry.isDirectory()) {
      walkAndGenerate(path.join(currentDir, entry.name), rootDirAbs, rootDirRel);
    }
  }
}

for (const rootDirRel of rootDirs) {
  const rootDirAbs = path.join(repoRoot, rootDirRel);
  if (fs.existsSync(rootDirAbs)) {
    walkAndGenerate(rootDirAbs, rootDirAbs, rootDirRel);
  } else {
    console.warn(`⚠ Directory not found: ${rootDirRel}`);
  }
}