Files
5chan/scripts/sync-directories.js
T
Tommaso CasaburiandGitHub 56894700c1 feat(oekaki): add drawing flow for /i/ (#1144)
* feat(oekaki): add drawing flow for /i/

* fix(oekaki): address review feedback

* fix(oekaki): reset Tegaki edit sessions

* fix(oekaki): block drawing during export

* fix(oekaki): destroy Tegaki on preload errors

* fix(oekaki): preserve drawing on export failure

* fix(oekaki): unlock controls after export failure
2026-05-30 15:06:47 +07:00

323 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Best-effort sync of per-directory 5chan lists from GitHub.
// Updates the vendored fallback in src/data/ so production builds ship a fresh snapshot.
// Never fails the build: if the fetch fails (offline, rate-limited, etc.), the existing file is kept.
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs';
import { isAbsolute, join, dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const GITHUB_CONTENTS_URL = 'https://api.github.com/repos/bitsocialnet/lists/contents/5chan-directories?ref=master';
const GITHUB_RAW_BASE_URL = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories';
const DIRECTORIES_SOURCE_PATH = process.env.DIRECTORIES_SOURCE_PATH;
const OUTPUT_PATH = join(__dirname, '..', 'src', 'data', '5chan-directory-lists.json');
const TIMEOUT_MS = 5000;
const DEFAULT_METADATA = {
title: '5chan directories',
description: 'Directory assignments built from per-directory candidate lists in https://github.com/bitsocialnet/lists/tree/master/5chan-directories',
createdAt: 0,
updatedAt: 0,
};
const DEFAULTS_FILE_NAME = '5chan-directories-defaults.json';
const DIRECTORY_LIST_FILE_RE = /^5chan-.+-directory\.json$/;
const DIRECTORY_CODE_ORDER = [
'a',
'co',
'ck',
'pol',
'biz',
'sci',
'g',
'v',
'vg',
'vr',
'fit',
'sp',
'tg',
'adv',
'wsg',
'diy',
'out',
'i',
'ic',
'mu',
'int',
'lit',
'his',
'tv',
't',
'x',
'vip',
'gif',
'bant',
'b',
'an',
];
const DIRECTORY_CODE_ORDER_INDEX = new Map(DIRECTORY_CODE_ORDER.map((code, index) => [code, index]));
const isRecord = (value) => typeof value === 'object' && value !== null;
const isDirectoryListFile = (fileName) => DIRECTORY_LIST_FILE_RE.test(fileName);
const toNumber = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
const toString = (value) => (typeof value === 'string' && value.length > 0 ? value : undefined);
const normalizeFeatures = (value) => {
if (!isRecord(value)) {
return undefined;
}
const normalizedFeatures = Object.entries(value).reduce((acc, [key, featureValue]) => {
if (typeof featureValue === 'string' || typeof featureValue === 'boolean' || typeof featureValue === 'number') {
acc[key] = featureValue;
}
return acc;
}, {});
return Object.keys(normalizedFeatures).length > 0 ? normalizedFeatures : undefined;
};
const deriveNsfw = ({ nsfw, features }) => {
const safeForWork = typeof features?.safeForWork === 'boolean' ? features.safeForWork : undefined;
if (safeForWork !== undefined) {
return !safeForWork;
}
const featuresNsfw = typeof features?.nsfw === 'boolean' ? features.nsfw : undefined;
return typeof nsfw === 'boolean' ? nsfw : featuresNsfw;
};
const normalizeBoard = (raw) => {
if (!isRecord(raw)) return null;
const address = toString(raw.address) || toString(raw.name);
if (!address) return null;
const features = normalizeFeatures(raw.features);
const nsfw = deriveNsfw({ nsfw: raw.nsfw, features });
const score = toNumber(raw.score);
return {
address,
...(toString(raw.publicKey) ? { publicKey: toString(raw.publicKey) } : {}),
...(toString(raw.owner) ? { owner: toString(raw.owner) } : {}),
...(score !== undefined ? { score } : {}),
...(toNumber(raw.addedAt) !== undefined ? { addedAt: toNumber(raw.addedAt) } : {}),
...(features ? { features } : {}),
...(nsfw !== undefined ? { nsfw } : {}),
};
};
const normalizeDirectoryDefaultsEntry = (code, raw) => {
if (!isRecord(raw)) {
return { directoryCode: code };
}
const features = normalizeFeatures(raw.features);
return {
directoryCode: toString(raw.directoryCode) || code,
...(toString(raw.title) ? { title: toString(raw.title) } : {}),
...(features ? { features } : {}),
};
};
const normalizeDirectoryDefaultsData = (raw) => {
const directoriesRaw = isRecord(raw) && isRecord(raw.directories) ? raw.directories : {};
const directories = Object.fromEntries(Object.entries(directoriesRaw).map(([code, value]) => [code, normalizeDirectoryDefaultsEntry(code, value)]));
return {
...(isRecord(raw) && toString(raw.title) ? { title: toString(raw.title) } : {}),
...(isRecord(raw) && toString(raw.description) ? { description: toString(raw.description) } : {}),
...(isRecord(raw) && toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
...(isRecord(raw) && toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
directories,
};
};
const normalizeDirectoryList = (raw, fallbackCode, defaults) => {
if (!isRecord(raw)) return null;
const boardsRaw = Array.isArray(raw.boards) ? raw.boards : Array.isArray(raw.communities) ? raw.communities : null;
if (!boardsRaw) return null;
const boards = boardsRaw.map(normalizeBoard).filter(Boolean);
if (boards.length === 0) return null;
const rawCode = toString(raw.directoryCode);
const defaultEntry = defaults?.directories?.[rawCode || fallbackCode] || defaults?.directories?.[fallbackCode];
const features = normalizeFeatures(defaultEntry?.features) || normalizeFeatures(raw.features);
return {
directoryCode: toString(defaultEntry?.directoryCode) || rawCode || fallbackCode,
...(toString(defaultEntry?.title) ? { title: toString(defaultEntry.title) } : toString(raw.title) ? { title: toString(raw.title) } : {}),
...(toString(raw.description) ? { description: toString(raw.description) } : {}),
...(features ? { features } : {}),
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
boards,
};
};
const getCodeFromFileName = (fileName) => fileName.replace(/^5chan-/, '').replace(/-directory\.json$/, '');
const sortDirectoryLists = (lists) =>
[...lists].sort((a, b) => {
const aIndex = DIRECTORY_CODE_ORDER_INDEX.get(a.directoryCode) ?? Number.MAX_SAFE_INTEGER;
const bIndex = DIRECTORY_CODE_ORDER_INDEX.get(b.directoryCode) ?? Number.MAX_SAFE_INTEGER;
if (aIndex !== bIndex) {
return aIndex - bIndex;
}
return a.directoryCode.localeCompare(b.directoryCode);
});
const toDirectoryListsData = (directories, fallbackMetadata = DEFAULT_METADATA) => {
const timestamps = [fallbackMetadata.createdAt, fallbackMetadata.updatedAt, ...directories.flatMap((list) => [list.createdAt, list.updatedAt])].filter(
(value) => typeof value === 'number',
);
return {
title: fallbackMetadata.title || DEFAULT_METADATA.title,
description: fallbackMetadata.description || DEFAULT_METADATA.description,
createdAt: timestamps.length > 0 ? Math.min(...timestamps) : fallbackMetadata.createdAt,
updatedAt: timestamps.length > 0 ? Math.max(...timestamps) : fallbackMetadata.updatedAt,
directories: sortDirectoryLists(directories),
};
};
const normalizeDirectoryListsData = (value, fallbackMetadata = DEFAULT_METADATA, defaults) => {
if (!isRecord(value) || !Array.isArray(value.directories)) {
return null;
}
const directories = value.directories
.map((directory, index) => {
if (!isRecord(directory)) {
return null;
}
const fallbackCode = toString(directory.directoryCode) || Object.keys(defaults?.directories || {})[index];
return fallbackCode ? normalizeDirectoryList(directory, fallbackCode, defaults) : null;
})
.filter(Boolean);
const metadata = {
title: toString(value.title) || fallbackMetadata.title,
description: toString(value.description) || fallbackMetadata.description,
createdAt: toNumber(value.createdAt) ?? fallbackMetadata.createdAt,
updatedAt: toNumber(value.updatedAt) ?? fallbackMetadata.updatedAt,
};
return directories.length > 0 ? toDirectoryListsData(directories, metadata) : null;
};
const getErrorMessage = (error) => (error instanceof Error ? error.message : String(error));
const fetchJson = async (url) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} finally {
clearTimeout(timeout);
}
};
const loadFromLocalDirectory = (directoryPath) => {
console.log(`️ Syncing vendored directories from local directory: ${directoryPath}`);
const defaultsPath = join(directoryPath, DEFAULTS_FILE_NAME);
const defaults = existsSync(defaultsPath) ? normalizeDirectoryDefaultsData(JSON.parse(readFileSync(defaultsPath, 'utf8'))) : normalizeDirectoryDefaultsData({});
const directories = readdirSync(directoryPath)
.filter(isDirectoryListFile)
.map((fileName) => {
const raw = JSON.parse(readFileSync(join(directoryPath, fileName), 'utf8'));
return normalizeDirectoryList(raw, getCodeFromFileName(fileName), defaults);
})
.filter(Boolean);
return toDirectoryListsData(directories, {
title: DEFAULT_METADATA.title,
description: DEFAULT_METADATA.description,
createdAt: defaults.createdAt ?? DEFAULT_METADATA.createdAt,
updatedAt: defaults.updatedAt ?? DEFAULT_METADATA.updatedAt,
});
};
const loadDirectoriesSource = async () => {
if (DIRECTORIES_SOURCE_PATH) {
const resolvedSourcePath = isAbsolute(DIRECTORIES_SOURCE_PATH) ? DIRECTORIES_SOURCE_PATH : resolve(process.cwd(), DIRECTORIES_SOURCE_PATH);
if (!existsSync(resolvedSourcePath)) {
throw new Error(`Local directories source not found: ${resolvedSourcePath}`);
}
if (statSync(resolvedSourcePath).isDirectory()) {
return loadFromLocalDirectory(resolvedSourcePath);
}
console.log(`️ Syncing vendored directories from local file: ${resolvedSourcePath}`);
return JSON.parse(readFileSync(resolvedSourcePath, 'utf8'));
}
console.log(`️ Syncing vendored directories from GitHub folder: ${GITHUB_CONTENTS_URL}`);
const contents = await fetchJson(GITHUB_CONTENTS_URL);
if (!Array.isArray(contents)) {
throw new Error('Invalid GitHub directory listing');
}
const fileNames = contents
.map((entry) => (isRecord(entry) ? entry.name : undefined))
.filter((name) => typeof name === 'string' && isDirectoryListFile(name))
.sort();
const defaults = normalizeDirectoryDefaultsData(await fetchJson(`${GITHUB_RAW_BASE_URL}/${DEFAULTS_FILE_NAME}`));
const directories = await Promise.all(
fileNames.map(async (fileName) => {
const raw = await fetchJson(`${GITHUB_RAW_BASE_URL}/${fileName}`);
return normalizeDirectoryList(raw, getCodeFromFileName(fileName), defaults);
}),
);
return toDirectoryListsData(directories.filter(Boolean), {
title: DEFAULT_METADATA.title,
description: DEFAULT_METADATA.description,
createdAt: defaults.createdAt ?? DEFAULT_METADATA.createdAt,
updatedAt: defaults.updatedAt ?? DEFAULT_METADATA.updatedAt,
});
};
const sync = async () => {
try {
let existing = '';
let fallbackMetadata = DEFAULT_METADATA;
try {
existing = readFileSync(OUTPUT_PATH, 'utf8');
const parsedExisting = JSON.parse(existing);
const normalizedExisting = normalizeDirectoryListsData(parsedExisting);
if (normalizedExisting) {
fallbackMetadata = {
title: normalizedExisting.title,
description: normalizedExisting.description,
createdAt: normalizedExisting.createdAt,
updatedAt: normalizedExisting.updatedAt,
};
}
} catch {
// File does not exist yet or is invalid JSON.
}
const data = normalizeDirectoryListsData(await loadDirectoriesSource(), fallbackMetadata);
if (!data) {
throw new Error('Invalid directory lists payload');
}
const formatted = `${JSON.stringify(data, null, 2)}\n`;
if (formatted === existing) {
console.log('✅ Vendored directory lists already up to date');
return;
}
writeFileSync(OUTPUT_PATH, formatted, 'utf8');
console.log(`✅ Synced vendored directory lists (${data.directories.length} directories)`);
} catch (e) {
console.warn(`⚠️ Could not sync directory lists from GitHub (keeping existing file): ${getErrorMessage(e)}`);
}
};
sync();