feat(flash board): add SWF posting support (#1145)

This commit is contained in:
Tommaso Casaburi
2026-05-30 16:07:41 +07:00
committed by GitHub
parent 56894700c1
commit 9b3a95dd95
69 changed files with 1372 additions and 63 deletions
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { FLASH_TAG_OPTIONS, getFlashTagOption, getFlashTagOptionFromComment, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectory } from '../flash-tags';
describe('flash-tags', () => {
it('defines the classic /f/ tag options with text-only post flairs', () => {
expect(FLASH_TAG_OPTIONS.map((option) => option.label)).toEqual(['Hentai', 'Porn', 'Japanese', 'Anime', 'Game', 'Loop', 'Other']);
expect(FLASH_TAG_OPTIONS.map((option) => option.flair)).toEqual([
{ text: 'flash:hentai' },
{ text: 'flash:porn' },
{ text: 'flash:japanese' },
{ text: 'flash:anime' },
{ text: 'flash:game' },
{ text: 'flash:loop' },
{ text: 'flash:other' },
]);
});
it('publishes a flash tag only on /f/', () => {
expect(getFlashTagPublishOptionsForDirectoryCode('f', 'loop')).toEqual({
flairs: [{ text: 'flash:loop' }],
});
expect(getFlashTagPublishOptionsForDirectoryCode('b', 'loop')).toEqual({
flairs: undefined,
});
});
it('does not publish a flair for missing or invalid selections', () => {
expect(getFlashTagOption(undefined)).toBeUndefined();
expect(getFlashTagOption('bad')).toBeUndefined();
expect(getFlashTagPublishOptionsForDirectoryCode('f', undefined)).toEqual({ flairs: undefined });
expect(getFlashTagPublishOptionsForDirectoryCode('f', 'bad')).toEqual({ flairs: undefined });
});
it('detects flash directories from code or title', () => {
expect(isFlashDirectory({ directoryCode: 'f' })).toBe(true);
expect(isFlashDirectory({ title: '/f/ - Flash' })).toBe(true);
expect(isFlashDirectory({ directoryCode: 'b' })).toBe(false);
});
it('extracts display tags from comment flairs', () => {
expect(getFlashTagOptionFromComment({ flairs: [{ text: 'flash:loop' }] })?.shortLabel).toBe('L');
expect(getFlashTagOptionFromComment({ flairs: [{ text: 'flag:country:US' }] })).toBeUndefined();
expect(getFlashTagOptionFromComment({ flairs: [{ text: 'flash:bad' }] })).toBeUndefined();
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { DirectoryCommunity } from './utils/directory-list-utils';
export type FlashTagCode = 'hentai' | 'porn' | 'japanese' | 'anime' | 'game' | 'loop' | 'other';
export interface FlashTagOption {
value: FlashTagCode;
label: string;
shortLabel: string;
flair: {
text: `flash:${FlashTagCode}`;
};
}
export const FLASH_TAG_OPTIONS: FlashTagOption[] = [
{ value: 'hentai', label: 'Hentai', shortLabel: 'H', flair: { text: 'flash:hentai' } },
{ value: 'porn', label: 'Porn', shortLabel: 'P', flair: { text: 'flash:porn' } },
{ value: 'japanese', label: 'Japanese', shortLabel: 'J', flair: { text: 'flash:japanese' } },
{ value: 'anime', label: 'Anime', shortLabel: 'A', flair: { text: 'flash:anime' } },
{ value: 'game', label: 'Game', shortLabel: 'G', flair: { text: 'flash:game' } },
{ value: 'loop', label: 'Loop', shortLabel: 'L', flair: { text: 'flash:loop' } },
{ value: 'other', label: 'Other', shortLabel: '?', flair: { text: 'flash:other' } },
];
const FLASH_TAG_OPTIONS_BY_CODE = new Map(FLASH_TAG_OPTIONS.map((option) => [option.value, option]));
const getDirectoryCode = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'title'> | undefined): string | undefined => {
const directoryCode = directory?.directoryCode?.trim().toLowerCase();
return directoryCode || directory?.title?.match(/^\/([^/]+)\//)?.[1]?.toLowerCase();
};
export const isFlashDirectoryCode = (directoryCode: string | undefined): boolean => directoryCode?.toLowerCase() === 'f';
export const isFlashDirectory = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'title'> | undefined): boolean =>
isFlashDirectoryCode(getDirectoryCode(directory));
export const getFlashTagOption = (value: string | undefined): FlashTagOption | undefined => FLASH_TAG_OPTIONS_BY_CODE.get(value as FlashTagCode);
export const getFlashTagPublishOptionsForDirectoryCode = (directoryCode: string | undefined, value: string | undefined) => {
if (!isFlashDirectoryCode(directoryCode)) {
return { flairs: undefined };
}
const option = getFlashTagOption(value);
return { flairs: option ? [option.flair] : undefined };
};
export const getFlashTagOptionFromComment = (comment: unknown): FlashTagOption | undefined => {
const flairs = comment && typeof comment === 'object' && Array.isArray((comment as { flairs?: unknown }).flairs) ? (comment as { flairs: unknown[] }).flairs : [];
for (const flair of flairs) {
if (!flair || typeof flair !== 'object') continue;
const text = (flair as { text?: unknown }).text;
if (typeof text !== 'string') continue;
const match = text.match(/^flash:([a-z]+)$/);
if (!match) continue;
const option = FLASH_TAG_OPTIONS_BY_CODE.get(match[1] as FlashTagCode);
if (option) return option;
}
return undefined;
};
@@ -21,6 +21,11 @@ describe('direct-url', () => {
expect(isDirectMediaUrl('https://example.com/video.gifv')).toBe(true);
});
it('returns true for Flash movie extensions', () => {
expect(isDirectMediaUrl('https://example.com/movie.swf')).toBe(true);
expect(isDirectMediaUrl('https://example.com/movie.SWF?download=1')).toBe(true);
});
it('strips query strings and fragments before checking', () => {
expect(isDirectMediaUrl('https://example.com/photo.jpg?size=large')).toBe(true);
expect(isDirectMediaUrl('https://example.com/page.html?img=photo.jpg')).toBe(false);
+3 -3
View File
@@ -1,7 +1,7 @@
/** File extensions that denote direct media URLs (images + videos) */
const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv'] as const;
/** File extensions that denote direct media URLs (images, videos, and Flash movies) */
const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv', '.swf'] as const;
/** Returns true if the URL appears to point to a direct media file (image or video) */
/** Returns true if the URL appears to point to a direct media file */
export function isDirectMediaUrl(url: string): boolean {
try {
const normalized = url.split('?')[0].split('#')[0].toLowerCase();
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { normalizeDirectoryList, sortDirectoryBoardsByRank } from '../directory-list-utils';
import { normalizeDirectoryList, sortDirectoryBoardsByRank, sortDirectoryLists } from '../directory-list-utils';
describe('directory-list-utils', () => {
it('preserves board scores and uses them for ranking', () => {
@@ -20,4 +20,36 @@ describe('directory-list-utils', () => {
]);
expect(sortDirectoryBoardsByRank(list?.boards ?? [])[0]?.address).toBe('higher-score.bso');
});
it('orders the Flash directory with the classic board set', () => {
expect(
sortDirectoryLists([
{ directoryCode: 'co', boards: [{ address: 'comics.bso' }] },
{ directoryCode: 'f', boards: [{ address: 'flash-posting.bso' }] },
{ directoryCode: 'a', boards: [{ address: 'anime.bso' }] },
]).map((list) => list.directoryCode),
).toEqual(['a', 'f', 'co']);
});
it('preserves rules from the directory list', () => {
const list = normalizeDirectoryList(
{
directoryCode: 'f',
rules: ['Tag uploaded files.'],
boards: [{ address: 'flash-posting.bso' }],
},
'f',
{
directories: {
f: {
directoryCode: 'f',
title: '/f/ - Flash',
rules: ['Ignored because defaults rules are not vendored.'],
},
},
},
);
expect(list?.rules).toEqual(['Tag uploaded files.']);
});
});
@@ -98,6 +98,7 @@ describe('media-utils', () => {
expect(getDisplayMediaInfoType('iframe', t)).toBe('translated:iframe');
expect(getDisplayMediaInfoType('video', t)).toBe('translated:video');
expect(getDisplayMediaInfoType('audio', t)).toBe('translated:audio');
expect(getDisplayMediaInfoType('swf', t)).toBe('SWF');
expect(getDisplayMediaInfoType('unknown', t)).toBe('translated:webpage');
});
@@ -107,6 +108,7 @@ describe('media-utils', () => {
expect(getHasThumbnail({ type: 'video', url: 'https://example.com/file.mp4' }, 'https://example.com/file.mp4')).toBe(true);
expect(getHasThumbnail({ type: 'audio', url: 'https://example.com/file.mp3' }, 'https://example.com/file.mp3')).toBe(true);
expect(getHasThumbnail({ type: 'gif', url: 'https://example.com/file.gif' }, 'https://example.com/file.gif')).toBe(true);
expect(getHasThumbnail({ type: 'swf', url: 'https://example.com/file.swf' }, 'https://example.com/file.swf')).toBe(true);
expect(getHasThumbnail({ thumbnail: 'https://example.com/thumb.png', type: 'webpage', url: 'https://example.com' }, 'https://example.com')).toBe(true);
expect(
getHasThumbnail(
@@ -132,6 +134,7 @@ describe('media-utils', () => {
expect(getLinkMediaInfo('https://example.com/file.png')).toMatchObject({ type: 'image' });
expect(getLinkMediaInfo('https://example.com/file.mp4')).toMatchObject({ type: 'video' });
expect(getLinkMediaInfo('https://example.com/file.mp3')).toMatchObject({ type: 'audio' });
expect(getLinkMediaInfo('https://example.com/file.swf')).toMatchObject({ type: 'swf' });
expect(getLinkMediaInfo('https://example.com/path')).toMatchObject({ type: 'webpage' });
expect(getLinkMediaInfo('https://www.youtube.com/watch?v=abc123')).toEqual({
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
@@ -201,6 +204,7 @@ describe('media-utils', () => {
expect(getMediaDimensions({ type: 'audio', url: 'https://example.com/file.mp3' })).toBe('700x240');
expect(getMediaDimensions({ linkHeight: 480, linkWidth: 640, type: 'image', url: 'https://example.com/file.png' })).toBe('640x480');
expect(getMediaDimensions({ linkHeight: 720, linkWidth: 1280, type: 'video', url: 'https://example.com/file.mp4' })).toBe('1280x720');
expect(getMediaDimensions({ linkHeight: 480, linkWidth: 640, type: 'swf', url: 'https://example.com/file.swf' })).toBe('640x480');
expect(getMediaDimensions({ type: 'webpage', url: 'https://example.com' })).toBe('');
});
+16
View File
@@ -45,6 +45,7 @@ export interface DirectoryList {
title?: string;
description?: string;
features?: DirectoryFeatures;
rules?: string[];
createdAt?: number;
updatedAt?: number;
boards: DirectoryListBoard[];
@@ -54,6 +55,7 @@ interface DirectoryDefaultsEntry {
directoryCode?: string;
title?: string;
features?: DirectoryFeatures;
rules?: string[];
}
export interface DirectoryDefaultsData {
@@ -66,6 +68,7 @@ export interface DirectoryDefaultsData {
const DIRECTORY_CODE_ORDER = [
'a',
'f',
'co',
'ck',
'pol',
@@ -121,6 +124,15 @@ const normalizeFeatures = (value: unknown): DirectoryFeatures | undefined => {
return Object.keys(normalizedFeatures).length > 0 ? normalizedFeatures : undefined;
};
const normalizeRules = (value: unknown): string[] | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const rules = value.filter((rule): rule is string => typeof rule === 'string' && rule.length > 0);
return rules.length > 0 ? rules : undefined;
};
const normalizeDirectoryDefaultsEntry = (code: string, raw: unknown): DirectoryDefaultsEntry => {
if (!isRecord(raw)) {
return { directoryCode: code };
@@ -128,10 +140,12 @@ const normalizeDirectoryDefaultsEntry = (code: string, raw: unknown): DirectoryD
const directoryCode = toString(raw.directoryCode) ?? code;
const features = normalizeFeatures(raw.features);
const rules = normalizeRules(raw.rules);
return {
directoryCode,
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
...(features ? { features } : {}),
...(rules ? { rules } : {}),
};
};
@@ -218,12 +232,14 @@ export const normalizeDirectoryList = (raw: unknown, fallbackCode: string, defau
const defaultEntry = defaults?.directories[rawCode ?? fallbackCode] ?? defaults?.directories[fallbackCode];
const directoryCode = toString(defaultEntry?.directoryCode) ?? rawCode ?? fallbackCode;
const features = normalizeFeatures(defaultEntry?.features) ?? normalizeFeatures(raw.features);
const rules = normalizeRules(raw.rules);
return {
directoryCode,
...(toString(defaultEntry?.title) ? { title: toString(defaultEntry?.title)! } : toString(raw.title) ? { title: toString(raw.title)! } : {}),
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
...(features ? { features } : {}),
...(rules ? { rules } : {}),
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
boards,
+8 -3
View File
@@ -33,6 +33,8 @@ export const getDisplayMediaInfoType = (type: string, t: Translate) => {
return t('video');
case 'audio':
return t('audio');
case 'swf':
return 'SWF';
default:
return t('webpage');
}
@@ -44,7 +46,7 @@ export const getHasThumbnail = memoize(
const { type, thumbnail, patternThumbnailUrl } = commentMediaInfo;
if (type === 'image' || type === 'video' || type === 'audio' || type === 'gif') return true;
if (type === 'image' || type === 'video' || type === 'audio' || type === 'gif' || type === 'swf') return true;
if (type === 'webpage' && thumbnail) return true;
if (type === 'iframe' && (patternThumbnailUrl || thumbnail)) return true;
@@ -79,7 +81,8 @@ const getPatternThumbnailUrl = (url: URL): string | undefined => {
const KNOWN_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'];
const KNOWN_VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'm4v'];
const KNOWN_AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma'];
const KNOWN_MEDIA_EXTENSIONS = new Set([...KNOWN_IMAGE_EXTENSIONS, ...KNOWN_VIDEO_EXTENSIONS, ...KNOWN_AUDIO_EXTENSIONS]);
const KNOWN_SWF_EXTENSIONS = ['swf'];
const KNOWN_MEDIA_EXTENSIONS = new Set([...KNOWN_IMAGE_EXTENSIONS, ...KNOWN_VIDEO_EXTENSIONS, ...KNOWN_AUDIO_EXTENSIONS, ...KNOWN_SWF_EXTENSIONS]);
// some sites don't show thumbnails, so the backend-side thumbnail fetching needs to be disabled, or it might fetch non-thumbnails such as emojis
const THUMBNAIL_BLACKLISTED_DOMAINS = ['twitter.com', 'x.com'];
@@ -154,6 +157,8 @@ export const getLinkMediaInfo = memoize(
type = 'video';
} else if (KNOWN_AUDIO_EXTENSIONS.includes(extension)) {
type = 'audio';
} else if (KNOWN_SWF_EXTENSIONS.includes(extension)) {
type = 'swf';
}
// Unknown extensions remain as 'webpage'
@@ -297,7 +302,7 @@ export const getMediaDimensions = memoize(
}
} else if (type === 'audio') {
return '700x240';
} else if (type === 'image' || type === 'video' || type === 'gif') {
} else if (type === 'image' || type === 'video' || type === 'gif' || type === 'swf') {
if (linkWidth && linkHeight) {
return `${linkWidth}x${linkHeight}`;
}