# core/persistence/image.persistence.ts

> 200 lines of code and 34 definitions.

Tree: Bane's Lab Social Share
Language: typescript
Layer: infrastructure
Canonical: https://banes-lab.com/anatomy/social-share#file-social-share-core-persistence-image-persistence-ts
Source text: https://banes-lab.com/source/social-share/core/persistence/image.persistence.ts.txt

Listed in [core/persistence](https://banes-lab.com/api/source/social-share/core/persistence.md), after [core/persistence/export.persistence.ts](https://banes-lab.com/source/social-share/core/persistence/export.persistence.ts.md).

## Definitions

- `ledgerKeyOf` (lexical_declaration, line 78, exported)
- `readLedger` (lexical_declaration, line 157, exported)
- `hashOf` (lexical_declaration, line 57, exported)
- `writeLedger` (lexical_declaration, line 190, exported)
- `staleEntries` (lexical_declaration, line 145, exported)
- `expectedImages` (lexical_declaration, line 113, exported)
- `writeImages` (lexical_declaration, line 168, exported)
- `writeShareMap` (lexical_declaration, line 215, exported)
- `sheetBytes` (lexical_declaration, line 45)
- `assetBytes` (lexical_declaration, line 49)
- `orphanImages` (lexical_declaration, line 121, exported)
- `renderShareMap` (lexical_declaration, line 205, exported)
- `outdatedJobs` (lexical_declaration, line 129, exported)
- `missingImages` (lexical_declaration, line 117, exported)
- `SLASH` (lexical_declaration, line 41)
- `HEX` (lexical_declaration, line 42)
- `TEXT` (lexical_declaration, line 43)
- `hash` (lexical_declaration, line 58, exported)
- `encoding` (lexical_declaration, line 59, exported)
- `publicRoot` (lexical_declaration, line 82)
- `shareFileIn` (lexical_declaration, line 86)
- `file` (lexical_declaration, line 87)
- `shareEntriesOf` (lexical_declaration, line 96, exported)
- `profile` (lexical_declaration, line 101, exported)
- `source` (lexical_declaration, line 105, exported)
- `stills` (lexical_declaration, line 106, exported)
- `expected` (lexical_declaration, line 125, exported)
- `isLedgerRow` (lexical_declaration, line 153)
- `pruneOrphans` (lexical_declaration, line 182, exported)
- `orphans` (lexical_declaration, line 183, exported)
- `live` (lexical_declaration, line 196, exported)
- `merged` (lexical_declaration, line 197, exported)
- `rows` (lexical_declaration, line 201, exported)
- `pairs` (lexical_declaration, line 206, exported)

## Contained in

- [core/persistence](https://banes-lab.com/anatomy/social-share/folder-social-share-core-persistence.md)

## Uses

- [core/converters/filename.converter.ts](https://banes-lab.com/source/social-share/core/converters/filename.converter.ts.md)
- [core/evaluators/card.evaluator.ts](https://banes-lab.com/source/social-share/core/evaluators/card.evaluator.ts.md)
- [core/timers/stage.timer.ts](https://banes-lab.com/source/social-share/core/timers/stage.timer.ts.md)

## Used by

- [core/coordinators/export.coordinator.ts](https://banes-lab.com/source/social-share/core/coordinators/export.coordinator.ts.md)
- [runtime/entrypoints/validation.entrypoint.ts](https://banes-lab.com/source/social-share/runtime/entrypoints/validation.entrypoint.ts.md)

## Linked from

- [core/converters](https://banes-lab.com/anatomy/social-share/folder-social-share-core-converters.md)
- [core/coordinators](https://banes-lab.com/anatomy/social-share/folder-social-share-core-coordinators.md)
- [core/evaluators](https://banes-lab.com/anatomy/social-share/folder-social-share-core-evaluators.md)
- [core/persistence](https://banes-lab.com/anatomy/social-share/folder-social-share-core-persistence.md)
- [core/timers](https://banes-lab.com/anatomy/social-share/folder-social-share-core-timers.md)
- [runtime/entrypoints](https://banes-lab.com/anatomy/social-share/folder-social-share-runtime-entrypoints.md)

## Source

```typescript
import {
    ARTICLE_PROFILES,
    ARTICLE_SCALE,
    GIF_COLORS,
    GIF_DITHER,
    GIF_PALETTE_STATS,
    OUTPUT_FRAME_MS,
    OUTPUT_RATE,
    OUTPUT_SCALES,
    SHARE_BYTE_LIMIT,
    SHARE_PROFILE,
    VIDEO_CRF,
    WEBP_QUALITY,
} from "#configuration/configs/card.config";
import type { CardSpec, Profile } from "#types/card.types";
import { FILE_SEPARATOR, HASH_ALGORITHM, STAGE_SHEETS } from "#configuration/constants/card.constants";
import {
    MALFORMED_LEDGER,
    MISSING_SHARE_PROFILE,
    SHARE_TOO_LARGE,
    SUBJECT_SEPARATOR,
} from "#configuration/strings/card.strings";
import type { RenderedImage, ShareEntry, ShareLedger } from "#types/image.types";
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import {
    imageFilesOf,
    outputsOf,
    profilesOf,
    shareCandidatesOf,
    stillOf,
} from "#core/converters/filename.converter";
import { join, relative, sep } from "node:path";
import { writeCanonicalText, writeVerbatim } from "@govlab/canonical-write";
import type { CaptureJob } from "#types/stage.types";
import { absolutePath } from "@ssot/paths";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import { resolveCard } from "#core/evaluators/card.evaluator";
import { samplesOf } from "#core/timers/stage.timer";

const SLASH = "/";
const HEX = "hex";
const TEXT = "utf8";

const sheetBytes = function sheetBytes(): readonly Buffer[] {
    return STAGE_SHEETS.map((sheet) => readFileSync(fileURLToPath(import.meta.resolve(sheet))));
};

const assetBytes = function assetBytes(spec: CardSpec): readonly Buffer[] {
    return spec.layers.flatMap((layer) =>
        layer.kind === "animation" || layer.kind === "image"
            ? [readFileSync(join(absolutePath("app.public"), layer.source))]
            : [],
    );
};

export const hashOf = function hashOf(spec: CardSpec, profile: Profile): string {
    const hash = createHash(HASH_ALGORITHM);
    const encoding = {
        colors: GIF_COLORS,
        crf: VIDEO_CRF,
        dither: GIF_DITHER,
        frameMs: OUTPUT_FRAME_MS,
        palette: GIF_PALETTE_STATS,
        quality: WEBP_QUALITY,
        scales: OUTPUT_SCALES,
    };
    hash.update(JSON.stringify({ encoding, timeline: spec.timeline }));
    for (const bytes of [...sheetBytes(), ...assetBytes(spec)]) {
        hash.update(bytes);
    }
    for (const sample of [spec.timeline.keyFrame, ...samplesOf(spec.timeline, OUTPUT_RATE)]) {
        hash.update(JSON.stringify(resolveCard(spec, profile, sample)));
    }
    return hash.digest(HEX);
};

export const ledgerKeyOf = function ledgerKeyOf(card: string, profile: string): string {
    return card + FILE_SEPARATOR + profile;
};

const publicRoot = function publicRoot(): string {
    return SLASH + relative(absolutePath("app.public"), absolutePath("app.shares")).split(sep).join(SLASH);
};

const shareFileIn = function shareFileIn(spec: CardSpec, profile: Profile, folder: string): string {
    const file = shareCandidatesOf(spec, profile).find(
        (candidate) => statSync(join(folder, candidate)).size <= SHARE_BYTE_LIMIT,
    );
    if (file === undefined) {
        throw new Error(spec.id + SUBJECT_SEPARATOR + SHARE_TOO_LARGE);
    }
    return file;
};

export const shareEntriesOf = function shareEntriesOf(
    specs: readonly CardSpec[],
    folder: string,
): readonly ShareEntry[] {
    return specs.map((spec) => {
        const profile = profilesOf(spec).find((candidate) => candidate.id === SHARE_PROFILE);
        if (profile === undefined) {
            throw new Error(spec.id + SUBJECT_SEPARATOR + MISSING_SHARE_PROFILE);
        }
        const source = publicRoot() + SLASH + shareFileIn(spec, profile, folder);
        const stills = profilesOf(spec)
            .filter((candidate) => ARTICLE_PROFILES.includes(candidate.id))
            .map((candidate) => publicRoot() + SLASH + stillOf(spec, candidate, ARTICLE_SCALE));
        return { alt: spec.alt, page: spec.page, source, stills };
    });
};

export const expectedImages = function expectedImages(specs: readonly CardSpec[]): ReadonlySet<string> {
    return new Set(specs.flatMap((spec) => profilesOf(spec).flatMap((profile) => imageFilesOf(spec, profile))));
};

export const missingImages = function missingImages(specs: readonly CardSpec[], folder: string): readonly string[] {
    return [...expectedImages(specs)].filter((file) => !existsSync(join(folder, file)));
};

export const orphanImages = function orphanImages(specs: readonly CardSpec[], folder: string): readonly string[] {
    if (!existsSync(folder)) {
        return [];
    }
    const expected = expectedImages(specs);
    return readdirSync(folder).filter((file) => !expected.has(file));
};

export const outdatedJobs = function outdatedJobs(
    specs: readonly CardSpec[],
    ledger: ShareLedger,
    folder: string,
): readonly CaptureJob[] {
    return specs.flatMap((spec) =>
        profilesOf(spec)
            .filter(
                (profile) =>
                    ledger.get(ledgerKeyOf(spec.id, profile.id)) !== hashOf(spec, profile) ||
                    imageFilesOf(spec, profile).some((file) => !existsSync(join(folder, file))),
            )
            .map((profile) => ({ profile, spec })),
    );
};

export const staleEntries = function staleEntries(specs: readonly CardSpec[], ledger: ShareLedger): readonly string[] {
    return specs.flatMap((spec) =>
        profilesOf(spec)
            .filter((profile) => ledger.get(ledgerKeyOf(spec.id, profile.id)) !== hashOf(spec, profile))
            .map((profile) => ledgerKeyOf(spec.id, profile.id)),
    );
};

const isLedgerRow = function isLedgerRow(row: unknown): row is readonly [string, string] {
    return Array.isArray(row) && row.length === 2 && row.every((cell) => typeof cell === "string");
};

export const readLedger = function readLedger(location: string): ShareLedger {
    if (!existsSync(location)) {
        return new Map();
    }
    const rows: unknown = JSON.parse(readFileSync(location, TEXT));
    if (!Array.isArray(rows) || !rows.every(isLedgerRow)) {
        throw new Error(MALFORMED_LEDGER + SUBJECT_SEPARATOR + location);
    }
    return new Map(rows);
};

export const writeImages = function writeImages(images: readonly RenderedImage[], folder: string): readonly string[] {
    mkdirSync(folder, { recursive: true });
    return images.flatMap((image) =>
        outputsOf(image).flatMap(([file, bytes]) => {
            if (bytes === null) {
                rmSync(join(folder, file), { force: true });
                return [];
            }
            writeVerbatim(join(folder, file), bytes);
            return [file];
        }),
    );
};

export const pruneOrphans = function pruneOrphans(specs: readonly CardSpec[], folder: string): readonly string[] {
    const orphans = orphanImages(specs, folder);
    for (const file of orphans) {
        rmSync(join(folder, file));
    }
    return orphans;
};

export const writeLedger = async function writeLedger(
    location: string,
    ledger: ShareLedger,
    images: readonly RenderedImage[],
    specs: readonly CardSpec[],
): Promise<void> {
    const live = new Set(specs.flatMap((spec) => profilesOf(spec).map((profile) => ledgerKeyOf(spec.id, profile.id))));
    const merged = new Map([...ledger].filter(([key]) => live.has(key)));
    for (const image of images) {
        merged.set(ledgerKeyOf(image.card, image.profile), image.hash);
    }
    const rows = [...merged].toSorted(([left], [right]) => left.localeCompare(right));
    await writeCanonicalText(location, JSON.stringify(rows));
};

export const renderShareMap = function renderShareMap(entries: readonly ShareEntry[]): string {
    const pairs = entries.map(({ page, ...share }) => [page, share]);
    return [
        'import type { ShareImage } from "#types/card.types";',
        "",
        `export const SHARE_IMAGES: ReadonlyMap<string, ShareImage> = new Map(JSON.parse(${JSON.stringify(JSON.stringify(pairs))}));`,
        "",
    ].join("\n");
};

export const writeShareMap = async function writeShareMap(
    location: string,
    entries: readonly ShareEntry[],
): Promise<void> {
    await writeCanonicalText(location, renderShareMap(entries));
};
```
