includes/archive.php

<?php
declare(strict_types=1);

require_once __DIR__ . '/core.php';

/*
|--------------------------------------------------------------------------
| Archive library + endpoint
|--------------------------------------------------------------------------
|
| Included from the dashboard:
|   Provides archive readers.
|
| Called directly:
|   Creates, verifies and purges yearly archives.
|
| Archives are stored per Brivacia site, for example:
|   /archives/2025-breat.json
|   /archives/2025-code.json
|
| This prevents a replace/import for one site from overwriting archived data
| belonging to another site.
|
*/

if (brivaciaArchiveIsEndpoint()) {
    require_once __DIR__ . '/modules/auth.php';

    loadTranslations();
    brivaciaRequireApiAuth();
    runYearArchiveEndpoint();
}


/*
|--------------------------------------------------------------------------
| Archive readers
|--------------------------------------------------------------------------
*/

function brivaciaArchiveIsEndpoint(): bool {
    return realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === realpath(__FILE__);
}

function archiveSafeSiteId(string $site): string {
    $site = strtolower(trim($site));
    $site = preg_replace('/[^a-z0-9_-]+/', '-', $site) ?? '';
    $site = trim($site, '-_');

    return $site !== '' ? $site : 'site';
}

function archiveFileName(int $year, string $site): string {
    return sprintf('%04d-%s.json', $year, archiveSafeSiteId($site));
}

function archiveFileYear(string $file): int {
    preg_match('/^(\d{4})(?:-[a-z0-9_-]+)?\.json$/i', basename($file), $match);

    return isset($match[1]) ? (int)$match[1] : 0;
}

function archiveFileSite(string $file): string {
    preg_match('/^\d{4}-([a-z0-9_-]+)\.json$/i', basename($file), $match);

    return isset($match[1]) ? (string)$match[1] : '';
}

function brivaciaArchiveFiles(): array {
    $files = glob(archiveDir() . '/*.json') ?: [];

    $files = array_values(array_filter($files, static function (string $file): bool {
        $name = basename($file);

        if ($name === 'legacy_stats.json') {
            return true;
        }

        return preg_match('/^\d{4}(?:-[a-z0-9_-]+)?\.json$/i', $name) === 1;
    }));

    sort($files, SORT_NATURAL | SORT_FLAG_CASE);

    return $files;
}

function brivaciaArchiveSummaryFile(): string {
    return archiveDir() . '/summary.json';
}

function brivaciaIsLegacyArchiveFile(string $file): bool {
    return basename($file) === 'legacy_stats.json';
}

function brivaciaArchiveFingerprint(): string {
    $parts = [];

    foreach (brivaciaArchiveFiles() as $file) {
        $parts[] = basename($file) . ':' . ((int)@filesize($file)) . ':' . ((int)@filemtime($file));
    }

    return hash('sha256', implode('|', $parts));
}

function brivaciaLegacyInt(mixed $value): int {
    return max(0, (int)$value);
}

function brivaciaLegacyDay(mixed $value): string {
    $value = (string)$value;

    return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
}

/*
|--------------------------------------------------------------------------
| Legacy archive: aggregate mode (hand-written, totals only)
|--------------------------------------------------------------------------
|
| Original, still-supported shape for people typing their old numbers by
| hand (no per-day dates available): one lifetime total per site. Rows
| carry no 'day' key, so archiveRows() naturally keeps them out of any
| ranged query (Today/Week/Month/Year) — they only ever show up on "All".
|
*/
function brivaciaAddAggregateLegacySite(array &$archive, string $site, array $siteData): void {
    $totals = is_array($siteData['totals'] ?? null)
        ? $siteData['totals']
        : [];

    $archive['days'][] = [
        'legacy' => 1,
        'site' => $site,
        'unique_visitors' => brivaciaLegacyInt($totals['unique_visitors'] ?? 0),
        'visits' => brivaciaLegacyInt($totals['visits'] ?? 0),
        'pageviews' => brivaciaLegacyInt($totals['pageviews'] ?? 0),
        'bots' => brivaciaLegacyInt($totals['bots'] ?? 0),
    ];

    foreach (($siteData['countries'] ?? []) as $country => $views) {
        // Reuse the same normalization as live tracking (uppercase,
        // 2 letters, falls back to 'XX'), so "fr" and "FR" in a
        // hand-written file are always treated as the same country
        // instead of silently creating a duplicate entry.
        $country = normalizeCountryCode((string)$country);

        $archive['countries'][] = [
            'legacy' => 1,
            'site' => $site,
            'country' => $country,
            'views' => brivaciaLegacyInt($views),
        ];
    }

    foreach (($siteData['referrers'] ?? []) as $referrer => $views) {
        $referrer = strtolower(trim((string)$referrer));

        if ($referrer === '') {
            continue;
        }

        $archive['referrers'][] = [
            'legacy' => 1,
            'site' => $site,
            'referrer' => $referrer,
            'views' => brivaciaLegacyInt($views),
        ];
    }
}

/*
|--------------------------------------------------------------------------
| Legacy archive: detailed mode (auto-exported, day by day)
|--------------------------------------------------------------------------
|
| Produced by Export -> Legacy statistics (detailed) from data that was
| already in Brivacia, so a real date is known for every row. Rows carry
| a 'day' key using the exact same shape as a regular yearly archive
| (day/site/unique_visitors/... for hits, day/site/country/views for
| countries, day/site/referrer/views for referrers), so the rest of
| archive.php (archiveRows, archiveGroupedViews, archiveSummary...)
| already knows how to consume them without any special-casing — they
| show up on Today/Week/Month/Year exactly like archived data would.
|
| No 'pages' section on purpose: legacy statistics never carry page-level
| detail, see docs/legacy-statistics.md.
|
*/
function brivaciaAddDetailedLegacySite(array &$archive, string $site, array $siteData): void {
    foreach (($siteData['days'] ?? []) as $row) {
        if (!is_array($row)) {
            continue;
        }

        $day = brivaciaLegacyDay($row['day'] ?? '');

        if ($day === '') {
            continue;
        }

        $archive['days'][] = [
            'legacy' => 1,
            'site' => $site,
            'day' => $day,
            'unique_visitors' => brivaciaLegacyInt($row['unique_visitors'] ?? 0),
            'visits' => brivaciaLegacyInt($row['visits'] ?? 0),
            'pageviews' => brivaciaLegacyInt($row['pageviews'] ?? 0),
            'bots' => brivaciaLegacyInt($row['bots'] ?? 0),
        ];
    }

    foreach (($siteData['countries'] ?? []) as $row) {
        if (!is_array($row)) {
            continue;
        }

        $day = brivaciaLegacyDay($row['day'] ?? '');
        $country = normalizeCountryCode((string)($row['country'] ?? ''));

        if ($day === '' || $country === '') {
            continue;
        }

        $archive['countries'][] = [
            'legacy' => 1,
            'site' => $site,
            'day' => $day,
            'country' => $country,
            'views' => brivaciaLegacyInt($row['views'] ?? 0),
        ];
    }

    foreach (($siteData['referrers'] ?? []) as $row) {
        if (!is_array($row)) {
            continue;
        }

        $day = brivaciaLegacyDay($row['day'] ?? '');
        $referrer = strtolower(trim((string)($row['referrer'] ?? '')));

        if ($day === '' || $referrer === '') {
            continue;
        }

        $archive['referrers'][] = [
            'legacy' => 1,
            'site' => $site,
            'day' => $day,
            'referrer' => $referrer,
            'views' => brivaciaLegacyInt($row['views'] ?? 0),
        ];
    }
}

function brivaciaNormalizeLegacyArchive(array $json): array {
    $archive = [
        'legacy' => true,
        'days' => [],
        'pages' => [],
        'countries' => [],
        'referrers' => [],
    ];

    $knownSites = brivacia_sites();

    foreach ($json as $site => $siteData) {
        $site = (string)$site;

        if ($site === '' || !isset($knownSites[$site]) || !is_array($siteData)) {
            continue;
        }

        // A site entry with a non-empty 'days' array is the detailed
        // (auto-exported) shape; anything else falls back to the
        // historical totals-only shape, so old hand-written files keep
        // working exactly as before.
        if (is_array($siteData['days'] ?? null) && $siteData['days'] !== []) {
            brivaciaAddDetailedLegacySite($archive, $site, $siteData);
        } else {
            brivaciaAddAggregateLegacySite($archive, $site, $siteData);
        }
    }

    return $archive;
}

function brivaciaLegacyHasDailyDetail(): bool {
    static $result = null;

    if ($result !== null) {
        return $result;
    }

    $file = archiveDir() . '/legacy_stats.json';

    if (!is_file($file)) {
        return $result = false;
    }

    foreach ((readArchiveFile($file)['days'] ?? []) as $row) {
        if ((string)($row['day'] ?? '') !== '') {
            return $result = true;
        }
    }

    return $result = false;
}

function readYearArchive(int $year): array {
    static $cache = [];

    if (isset($cache[$year])) {
        return $cache[$year];
    }

    $merged = [
        'year' => $year,
        'generated_at' => '',
        'days' => [],
        'pages' => [],
        'countries' => [],
        'referrers' => [],
    ];

    foreach (brivaciaArchiveFiles() as $file) {
        if (archiveFileYear($file) !== $year) {
            continue;
        }

        $archive = readArchiveFile($file);

        foreach (['days', 'pages', 'countries', 'referrers'] as $section) {
            foreach (($archive[$section] ?? []) as $row) {
                $merged[$section][] = $row;
            }
        }

        if (($archive['generated_at'] ?? '') !== '') {
            $merged['generated_at'] = (string)$archive['generated_at'];
        }
    }

    return $cache[$year] = $merged;
}

function readArchiveFile(string $file): array {
    static $cache = [];

    if (isset($cache[$file])) {
        return $cache[$file];
    }

    if (brivaciaIsLegacyArchiveFile($file)) {
        return $cache[$file] = brivaciaReadLegacyArchive($file);
    }

    $json = json_decode((string)file_get_contents($file), true);
    $json = is_array($json) ? $json : [];

    return $cache[$file] = $json;
}

/*
|--------------------------------------------------------------------------
| Legacy archive: compiled cache
|--------------------------------------------------------------------------
|
| A detailed legacy export can be a large JSON file (thousands of
| day/country/referrer rows). Re-parsing that JSON and rebuilding every
| normalized row on every single dashboard request — which now includes
| "today", the most common view — is real, avoidable work.
|
| Instead, the normalized result is written once as a plain PHP array
| literal (var_export), which `include` can load without any JSON
| decoding, and which PHP's opcache can cache as compiled bytecode across
| requests. The cache is only rebuilt when legacy_stats.json actually
| changes (mtime + filesize fingerprint, same idea as
| brivaciaArchiveFingerprint() above).
|
*/
function brivaciaLegacyCacheFile(string $file): string {
    return dirname($file) . '/.' . basename($file) . '.cache.php';
}

function brivaciaReadLegacyArchive(string $file): array {
    $fingerprint = (@filemtime($file) ?: 0) . ':' . (@filesize($file) ?: 0);
    $cacheFile = brivaciaLegacyCacheFile($file);

    if (is_file($cacheFile)) {
        $cached = include $cacheFile;

        if (is_array($cached) && ($cached['fingerprint'] ?? '') === $fingerprint) {
            return $cached['archive'];
        }
    }

    $json = json_decode((string)file_get_contents($file), true);
    $archive = brivaciaNormalizeLegacyArchive(is_array($json) ? $json : []);

    $payload = "<?php\ndeclare(strict_types=1);\nreturn "
        . var_export(['fingerprint' => $fingerprint, 'archive' => $archive], true)
        . ";\n";

    $tmp = $cacheFile . '.' . uniqid('', true) . '.tmp';

    // Best-effort: if the directory isn't writable for some reason, just
    // skip caching rather than fail the whole request over it.
    if (file_put_contents($tmp, $payload, LOCK_EX) !== false) {
        rename($tmp, $cacheFile);
    }

    return $archive;
}

function archiveYearOverlaps(int $year, ?string $start, ?string $end): bool {
    if ($start === null || $end === null) {
        return true;
    }

    return "$year-12-31" >= $start && "$year-01-01" <= $end;
}

function archiveRows(string $section, ?string $start = null, ?string $end = null): array {
    $rows = [];

    foreach (brivaciaArchiveFiles() as $file) {
        $isLegacy = brivaciaIsLegacyArchiveFile($file);
        $year = archiveFileYear($file);

        // Legacy has no year in its filename, so it never goes through
        // the year-overlap check. Whether its rows actually belong to the
        // requested range is decided below, per row, using 'day' — an
        // aggregate (totals-only) row has no 'day' and is excluded by
        // that same filter, so it only ever surfaces on "All" as before.
        if (!$isLegacy && ($year <= 0 || !archiveYearOverlaps($year, $start, $end))) {
            continue;
        }

        $archive = readArchiveFile($file);

        foreach (($archive[$section] ?? []) as $row) {
            $day = (string)($row['day'] ?? '');

            if ($start !== null && ($day < $start || $day > $end)) {
                continue;
            }

            $rows[] = $row;
        }
    }

    return $rows;
}

function archiveHitsTotals(
    ?string $start = null,
    ?string $end = null,
    string $site = ''
): array {
    $totals = [
        'unique_visitors' => 0,
        'visits' => 0,
        'pageviews' => 0,
        'bots' => 0,
    ];

    /*
     * The summary is global and can only be used when no site filter
     * is requested.
     */
    if ($start === null && $end === null && $site === '') {
        return array_merge($totals, archiveSummary()['hits'] ?? []);
    }

    foreach (archiveRows('days', $start, $end) as $row) {
        if (
            $site !== ''
            && (string)($row['site'] ?? '') !== $site
        ) {
            continue;
        }

        foreach ($totals as $key => $_) {
            $totals[$key] += (int)($row[$key] ?? 0);
        }
    }

    return $totals;
}

function archiveGroupedViews(
    string $section,
    string $keyName,
    ?string $start = null,
    ?string $end = null,
    string $site = ''
): array {
    if (
        $start === null
        && $end === null
        && $site === ''
        && in_array($section, ['countries', 'referrers'], true)
    ) {
        return archiveSummary()[$section] ?? [];
    }

    $grouped = [];

    foreach (archiveRows($section, $start, $end) as $row) {
        if (
            $site !== ''
            && (string)($row['site'] ?? '') !== $site
        ) {
            continue;
        }

        $key = (string)($row[$keyName] ?? '');

        if ($key === '') {
            continue;
        }

        $grouped[$key] ??= [
            $keyName => $key,
            'views' => 0,
        ];

        $grouped[$key]['views'] += (int)($row['views'] ?? 0);
    }

    $grouped = array_values($grouped);

    usort(
        $grouped,
        fn($a, $b) => $b['views'] <=> $a['views']
    );

    return $grouped;
}

function archivePages(?string $start = null, ?string $end = null): array {
    $pages = [];

    foreach (archiveRows('pages', $start, $end) as $row) {
        $site = (string)($row['site'] ?? '');
        $pageKey = (string)($row['page_key'] ?? '');

        if ($site === '' || $pageKey === '') {
            continue;
        }

        $key = $site . "\0" . $pageKey;

        $pages[$key] ??= [
            'site' => $site,
            'page_key' => $pageKey,
            'title' => '',
            'url' => '',
            'views' => 0,
            'page_resolved' => 0,
        ];

        $pages[$key]['views'] += (int)($row['views'] ?? 0);

        $resolved = (int)($row['page_resolved'] ?? 0);
        $title = (string)($row['title'] ?? '');
        $url = (string)($row['url'] ?? '');

        if ($resolved === 1 || $pages[$key]['title'] === '') {
            if ($title !== '') {
                $pages[$key]['title'] = $title;
            }

            if ($url !== '') {
                $pages[$key]['url'] = $url;
            }

            $pages[$key]['page_resolved'] = max(
                $pages[$key]['page_resolved'],
                $resolved
            );
        }
    }

    $pages = array_values($pages);

    usort(
        $pages,
        fn($a, $b) =>
            ($b['views'] <=> $a['views']) ?:
            strcmp((string)$a['title'], (string)$b['title'])
    );

    return $pages;
}


/*
|--------------------------------------------------------------------------
| Archive summary cache
|--------------------------------------------------------------------------
|
| The dashboard needs all-time counters on every page. Reading every yearly
| archive for each card is wasteful, so closed-year totals are cached here.
|
| The summary intentionally aggregates every per-site archive, because the
| current dashboard is global. A future site filter can add a site-aware
| summary without changing the archive file format.
|
*/

function rebuildArchiveSummary(): array {
    $summary = [
        'generated_at' => date('c'),
        'fingerprint' => brivaciaArchiveFingerprint(),
        'years' => [],
        'sites' => [],
        'hits' => [
            'unique_visitors' => 0,
            'visits' => 0,
            'pageviews' => 0,
            'bots' => 0,
        ],
        'countries' => [],
        'referrers' => [],
    ];

    $countries = [];
    $referrers = [];

    foreach (brivaciaArchiveFiles() as $file) {
        $archive = readArchiveFile($file);
        $year = (int)($archive['year'] ?? archiveFileYear($file));

        if ($year > 0) {
            $summary['years'][] = $year;
        }

        $sitesInArchive = [];

        $site = (string)($archive['site'] ?? archiveFileSite($file));

        if ($site !== '') {
            $sitesInArchive[] = $site;
        }

        foreach (['days', 'pages', 'countries', 'referrers'] as $section) {
            foreach (($archive[$section] ?? []) as $row) {
                $rowSite = (string)($row['site'] ?? '');

                if ($rowSite !== '') {
                    $sitesInArchive[] = $rowSite;
                }
            }
        }

        foreach (array_unique($sitesInArchive) as $archiveSite) {
            $summary['sites'][] = $archiveSite;
        }

        foreach (($archive['days'] ?? []) as $row) {
            foreach ($summary['hits'] as $key => $_) {
                $summary['hits'][$key] += (int)($row[$key] ?? 0);
            }
        }

        foreach (($archive['countries'] ?? []) as $row) {
            $country = (string)($row['country'] ?? '');

            if ($country === '') {
                continue;
            }

            $countries[$country] = ($countries[$country] ?? 0) + (int)($row['views'] ?? 0);
        }

        foreach (($archive['referrers'] ?? []) as $row) {
            $referrer = (string)($row['referrer'] ?? '');

            if ($referrer === '') {
                continue;
            }

            $referrers[$referrer] = ($referrers[$referrer] ?? 0) + (int)($row['views'] ?? 0);
        }
    }

    $summary['years'] = array_values(array_unique($summary['years']));
    sort($summary['years'], SORT_NUMERIC);

    $summary['sites'] = array_values(array_unique($summary['sites']));
    sort($summary['sites'], SORT_NATURAL | SORT_FLAG_CASE);

    foreach ($countries as $country => $views) {
        $summary['countries'][] = [
            'country' => $country,
            'views' => $views,
        ];
    }

    foreach ($referrers as $referrer => $views) {
        $summary['referrers'][] = [
            'referrer' => $referrer,
            'views' => $views,
        ];
    }

    usort($summary['countries'], fn($a, $b) => $b['views'] <=> $a['views']);
    usort($summary['referrers'], fn($a, $b) => $b['views'] <=> $a['views']);

    $file = brivaciaArchiveSummaryFile();
    $tmp = $file . '.tmp';

    file_put_contents(
        $tmp,
        json_encode(
            $summary,
            JSON_PRETTY_PRINT |
            JSON_UNESCAPED_UNICODE |
            JSON_UNESCAPED_SLASHES
        ),
        LOCK_EX
    );

    rename($tmp, $file);

    return $summary;
}

function archiveSummary(): array {
    static $summary = null;

    if (is_array($summary)) {
        return $summary;
    }

    $file = brivaciaArchiveSummaryFile();
    $fingerprint = brivaciaArchiveFingerprint();

    if (is_file($file)) {
        $json = json_decode((string)file_get_contents($file), true);

        if (
            is_array($json) &&
            (string)($json['fingerprint'] ?? '') === $fingerprint
        ) {
            return $summary = $json;
        }
    }

    return $summary = rebuildArchiveSummary();
}


/*
|--------------------------------------------------------------------------
| Archive endpoint
|--------------------------------------------------------------------------
*/

function runYearArchiveEndpoint(): void {
    $db = brivaciaDb();
    $year = (int)($_GET['year'] ?? ((int)date('Y') - 1));
    $requestedSite = trim((string)($_GET['site'] ?? ''));

    if ($year < 2020 || $year >= (int)date('Y')) {
        brivaciaLog('archive/invalid.log', 'invalid year=' . $year);

        http_response_code(400);
        exit(t('archive.invalid.year'));
    }

    try {
        brivaciaLog(
            'archive/year.log',
            'start year=' . $year . ' site=' . ($requestedSite !== '' ? $requestedSite : 'all')
        );

        $sites = $requestedSite !== ''
            ? [$requestedSite]
            : archiveSitesForYear($db, $year);

        $files = [];

        foreach ($sites as $site) {
            $file = createYearArchive($db, $year, $site);
            verifyYearArchive($file, $year, $site);
            purgeArchivedYear($db, $year, $site);

            $files[] = basename($file);

            brivaciaLog(
                'archive/year.log',
                'success year=' . $year . ' site=' . $site . ' file=' . basename($file)
            );
        }

        rebuildArchiveSummary();

    } catch (Throwable $e) {
        brivaciaLog(
            'archive/failed.log',
            'failed year=' . $year . ' site=' . ($requestedSite !== '' ? $requestedSite : 'all') . ' error=' . $e->getMessage()
        );

        http_response_code(500);
        exit(t('archive.failed'));
    }

    header('Content-Type: text/plain; charset=utf-8');

    echo t('archive.created') . ' : ' . implode(', ', $files) . PHP_EOL;
    echo t('archive.sqlite.purged') . " $year";

    exit;
}

function archiveSitesForYear(PDO $db, int $year): array {
    $start = sprintf('%04d-01-01', $year);
    $end = sprintf('%04d-12-31', $year);

    $rows = fetchAll($db, '
        SELECT DISTINCT site
        FROM hits_daily
        WHERE day BETWEEN ? AND ?
        AND site != ""
        ORDER BY site
    ', [$start, $end]);

    return array_values(array_filter(array_map(
        static fn(array $row): string => (string)($row['site'] ?? ''),
        $rows
    )));
}

function createYearArchive(PDO $db, int $year, string $site): string {
    $site = trim($site);
    $start = sprintf('%04d-01-01', $year);
    $end = sprintf('%04d-12-31', $year);

    if ($site === '') {
        throw new RuntimeException('Archive site is missing');
    }

    /*
    |--------------------------------------------------------------------------
    | Resolve important page labels before freezing a closed year
    |--------------------------------------------------------------------------
    |
    | Archive files are JSON snapshots. Once a year is archived, its SQLite rows
    | are purged, so raw imported page labels would otherwise stay raw forever in
    | the "all time" dashboard view.
    |
    */

    refreshPageLabelsForRange(
        $db,
        'site = ? AND day BETWEEN ? AND ?',
        [$site, $start, $end],
        100
    );

    // Same reasoning as above, for the page language ('xx:' alert marker
    // or a bare pre-fix legacy key): once this year is purged, the regular
    // per-pixel-hit trickle (refreshPageLanguages()) will never get
    // another chance at it.
    refreshPageLanguagesForRange(
        $db,
        'site = ? AND day BETWEEN ? AND ?',
        [$site, $start, $end],
        100
    );

    $pages = fetchAll(
        $db,
        'SELECT * FROM pages_daily WHERE site = ? AND day BETWEEN ? AND ? ORDER BY day, views DESC',
        [$site, $start, $end]
    );

    foreach ($pages as &$page) {
        $pageSite = (string)($page['site'] ?? $site);
        $pageKey = (string)($page['page_key'] ?? '');
        $rawTitle = (string)($page['title'] ?? '');
        $rawUrl = (string)($page['url'] ?? '');
        $displayUrl = pageUrl($pageSite, $pageKey, $rawUrl);

        $page['url'] = $displayUrl;
        $page['title'] = cleanPageTitle(
            $rawTitle !== '' ? $rawTitle : $pageKey,
            $pageSite,
            $pageKey,
            $displayUrl
        );
    }
    unset($page);

    $archive = [
        'year' => $year,
        'site' => $site,
        'generated_at' => date('c'),
        'days' => fetchAll(
            $db,
            'SELECT * FROM hits_daily WHERE site = ? AND day BETWEEN ? AND ? ORDER BY day',
            [$site, $start, $end]
        ),
        'pages' => $pages,
        'countries' => fetchAll(
            $db,
            'SELECT * FROM countries_daily WHERE site = ? AND day BETWEEN ? AND ? ORDER BY day, views DESC',
            [$site, $start, $end]
        ),
        'referrers' => fetchAll(
            $db,
            'SELECT * FROM referrers_daily WHERE site = ? AND day BETWEEN ? AND ? ORDER BY day, views DESC',
            [$site, $start, $end]
        ),
    ];

    $file = archiveDir() . '/' . archiveFileName($year, $site);
    $tmp = $file . '.tmp';

    file_put_contents(
        $tmp,
        json_encode(
            $archive,
            JSON_PRETTY_PRINT |
            JSON_UNESCAPED_UNICODE |
            JSON_UNESCAPED_SLASHES
        ),
        LOCK_EX
    );

    if (!is_file($tmp) || filesize($tmp) < 50) {
        throw new RuntimeException('Archive write failed');
    }

    rename($tmp, $file);

    return $file;
}

function verifyYearArchive(string $file, int $year, string $site = ''): void {
    if (!is_file($file) || filesize($file) < 50) {
        throw new RuntimeException('Archive file missing or empty');
    }

    $json = json_decode((string)file_get_contents($file), true);

    if (
        !is_array($json) ||
        ($json['year'] ?? null) !== $year ||
        ($site !== '' && (string)($json['site'] ?? '') !== $site) ||
        !isset($json['generated_at']) ||
        !isset($json['days'], $json['pages'], $json['countries'], $json['referrers']) ||
        !is_array($json['days']) ||
        !is_array($json['pages']) ||
        !is_array($json['countries']) ||
        !is_array($json['referrers'])
    ) {
        throw new RuntimeException('Archive verification failed');
    }
}

function purgeArchivedYear(PDO $db, int $year, string $site): void {
    $site = trim($site);
    $start = sprintf('%04d-01-01', $year);
    $end = sprintf('%04d-12-31', $year);

    if ($site === '') {
        throw new RuntimeException('Archive purge site is missing');
    }

    $tables = [
        'hits_daily',
        'hits_hourly',
        'pages_daily',
        'countries_daily',
        'referrers_daily',
        'seen_daily',
        'visitor_sessions',
    ];

    $db->beginTransaction();

    try {
        foreach ($tables as $table) {
            $db->prepare("DELETE FROM $table WHERE site = ? AND day BETWEEN ? AND ?")
                ->execute([$site, $start, $end]);
        }

        $db->commit();
        $db->exec('VACUUM');

    } catch (Throwable $e) {
        $db->rollBack();

        throw $e;
    }
}

function archiveClosedYears(PDO $db): array {
    $currentYear = (int)date('Y');
    $rows = fetchAll($db, '
        SELECT
            site,
            CAST(substr(day, 1, 4) AS INTEGER) AS year
        FROM hits_daily
        WHERE CAST(substr(day, 1, 4) AS INTEGER) < ?
        AND site != ""
        GROUP BY site, CAST(substr(day, 1, 4) AS INTEGER)
        ORDER BY year, site
    ', [$currentYear]);

    $archived = [];

    foreach ($rows as $row) {
        $site = (string)($row['site'] ?? '');
        $year = (int)($row['year'] ?? 0);

        if ($site === '' || $year < 2020 || $year >= $currentYear) {
            continue;
        }

        brivaciaLog('archive/year.log', 'auto start year=' . $year . ' site=' . $site);

        $file = createYearArchive($db, $year, $site);
        verifyYearArchive($file, $year, $site);
        purgeArchivedYear($db, $year, $site);

        brivaciaLog('archive/year.log', 'auto success year=' . $year . ' site=' . $site);

        $archived[] = $year . '-' . $site;
    }

    if ($archived !== []) {
        rebuildArchiveSummary();
    }

    return $archived;
}

Contribute