test: Add resolveTemplate and validateTemplate methods to FileOrganizer…

- "src/services/file-organizer.ts"
- "src/__tests__/file-organizer.test.ts"

GSD-Task: S02/T02
This commit is contained in:
jlightner 2026-04-04 05:23:22 +00:00
parent e6371ba196
commit 71175198bd
2 changed files with 356 additions and 9 deletions

View file

@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { FileOrganizer } from '../services/file-organizer';
import { FileOrganizer, DEFAULT_OUTPUT_TEMPLATE, TEMPLATE_VARIABLES } from '../services/file-organizer';
let tmpDir: string;
@ -213,4 +213,230 @@ describe('FileOrganizer', () => {
expect(result).not.toMatch(/\\{2,}/);
});
});
describe('resolveTemplate', () => {
it('replaces all known variables', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{platform}/{channel}/{title}.{ext}', {
platform: 'youtube',
channel: 'TechChannel',
title: 'My Video',
ext: 'mp4',
});
expect(result).toBe('youtube/TechChannel/My Video.mp4');
});
it('handles date/year/month variables', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{platform}/{year}/{month}/{title}.{ext}', {
platform: 'youtube',
year: '2026',
month: '04',
title: 'April Video',
ext: 'mkv',
});
expect(result).toBe('youtube/2026/04/April Video.mkv');
});
it('handles contentType and id variables', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{contentType}/{id}.{ext}', {
contentType: 'video',
id: 'abc-123',
ext: 'mp4',
});
expect(result).toBe('video/abc-123.mp4');
});
it('sanitizes variable values (strips forbidden chars)', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{channel}/{title}.{ext}', {
channel: 'Bad:Channel*Name',
title: 'Title "With" <Special>',
ext: 'mp4',
});
expect(result).toBe('BadChannelName/Title With Special.mp4');
});
it('resolves missing known variables to empty string', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{platform}/{channel}/{title}.{ext}', {
platform: 'youtube',
ext: 'mp4',
// channel and title missing
});
// Missing vars resolve to empty → sanitizeFilename('') → '_unnamed'
expect(result).toBe('youtube/_unnamed/_unnamed.mp4');
});
it('leaves unknown variables untouched', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{platform}/{unknown}/{title}.{ext}', {
platform: 'youtube',
title: 'Video',
ext: 'mp4',
});
expect(result).toBe('youtube/{unknown}/Video.mp4');
});
it('handles template with no variables', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('static/path/file.mp4', {});
expect(result).toBe('static/path/file.mp4');
});
it('handles special characters in variable values', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{channel}/{title}.{ext}', {
channel: '🎵 Music Channel 🎶',
title: 'Ünîcödé Söng',
ext: 'flac',
});
expect(result).toBe('🎵 Music Channel 🎶/Ünîcödé Söng.flac');
});
it('does not sanitize the ext variable', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{title}.{ext}', {
title: 'Video',
ext: 'mp4',
});
// ext should be raw, not run through sanitizeFilename
expect(result).toBe('Video.mp4');
});
});
describe('validateTemplate', () => {
it('accepts the default template', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate(DEFAULT_OUTPUT_TEMPLATE);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('accepts a complex valid template', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('{platform}/{channel}/{year}/{month}/{title}.{ext}');
expect(result.valid).toBe(true);
});
it('rejects template without {ext}', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('{platform}/{channel}/{title}');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Template must contain {ext} for the file extension');
});
it('rejects empty template', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Template must not be empty');
});
it('rejects whitespace-only template', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate(' ');
expect(result.valid).toBe(false);
});
it('flags unknown variable names', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('{platform}/{bogus}/{title}.{ext}');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Unknown template variable: {bogus}');
});
it('flags illegal filesystem characters', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('{platform}/<bad>/{title}.{ext}');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Template contains illegal filesystem characters');
});
it('accumulates multiple errors', () => {
const fo = new FileOrganizer('/media');
// Missing {ext} AND unknown variable
const result = fo.validateTemplate('{platform}/{bogus}/{title}');
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThanOrEqual(2);
});
it('accepts template with only {ext}', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('{title}.{ext}');
expect(result.valid).toBe(true);
});
});
describe('buildOutputPath with template', () => {
it('default template produces identical paths to legacy behavior', () => {
const fo = new FileOrganizer('/media');
// Legacy (no template)
const legacy = fo.buildOutputPath('youtube', 'TechChannel', 'My Video', 'mp4');
// Template (explicit default)
const templated = fo.buildOutputPath('youtube', 'TechChannel', 'My Video', 'mp4', DEFAULT_OUTPUT_TEMPLATE);
expect(templated).toBe(legacy);
});
it('custom template changes directory structure', () => {
const fo = new FileOrganizer('/media');
const result = fo.buildOutputPath(
'youtube',
'TechChannel',
'My Video',
'mp4',
'{platform}/{title}.{ext}'
);
// Should be /media/youtube/My Video.mp4 — no channel directory
expect(result).toContain('youtube');
expect(result).toContain('My Video.mp4');
expect(result).not.toContain('TechChannel');
});
it('template with year/month creates date-based directories', () => {
const fo = new FileOrganizer('/media');
const result = fo.buildOutputPath(
'youtube',
'TechChannel',
'My Video',
'mp4',
'{platform}/{channel}/{year}/{title}.{ext}'
);
const year = String(new Date().getFullYear());
expect(result).toContain(year);
expect(result).toContain('TechChannel');
expect(result).toContain('My Video.mp4');
});
it('still sanitizes values when using custom template', () => {
const fo = new FileOrganizer('/media');
const result = fo.buildOutputPath(
'youtube',
'Bad:Channel*Name',
'Title "With" <Special>',
'mkv',
'{platform}/{channel}/{title}.{ext}'
);
expect(result).not.toContain(':');
expect(result).not.toContain('*');
expect(result).not.toContain('"');
expect(result).toContain('BadChannelName');
expect(result).toContain('Title With Special.mkv');
});
});
describe('constants and types', () => {
it('DEFAULT_OUTPUT_TEMPLATE matches legacy layout', () => {
expect(DEFAULT_OUTPUT_TEMPLATE).toBe('{platform}/{channel}/{title}.{ext}');
});
it('TEMPLATE_VARIABLES includes all expected variables', () => {
const expected = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'];
for (const v of expected) {
expect(TEMPLATE_VARIABLES).toContain(v);
}
});
});
});

View file

@ -20,32 +20,153 @@ const MAX_FILENAME_LENGTH = 200;
/** Maximum attempts to find a unique filename. */
const MAX_UNIQUE_ATTEMPTS = 100;
/** Default output path template — matches the legacy hardcoded layout. */
export const DEFAULT_OUTPUT_TEMPLATE = '{platform}/{channel}/{title}.{ext}';
/** All supported template variable names. */
export const TEMPLATE_VARIABLES = [
'platform',
'channel',
'title',
'date',
'year',
'month',
'contentType',
'id',
'ext',
] as const;
export type TemplateVariable = (typeof TEMPLATE_VARIABLES)[number];
/** Variables that callers can supply when resolving a template. */
export type TemplateVars = Partial<Record<TemplateVariable, string>>;
/** Result of template validation. */
export interface TemplateValidationResult {
valid: boolean;
errors: string[];
}
// ── Regex helpers ──
/** Matches a `{variableName}` placeholder. */
const TEMPLATE_VAR_RE = /\{([a-zA-Z]+)\}/g;
/** Characters illegal in path segments (beyond what sanitizeFilename strips). */
const ILLEGAL_PATH_CHARS = /[<>"|?*\x00-\x1f]/;
// ── FileOrganizer ──
/**
* Builds structured output paths from content metadata and sanitizes
* filenames for cross-platform safety.
*
* Path template: `{mediaPath}/{platform}/{channelName}/{title}.{ext}`
* filenames for cross-platform safety. Supports configurable path
* templates with variables like {platform}, {channel}, {title}, {ext}.
*/
export class FileOrganizer {
constructor(private readonly mediaPath: string) {}
/**
* Build the full output path for a downloaded file.
* Sanitizes channelName and title for filesystem safety.
*
* When `template` is provided, resolves it against the supplied metadata.
* Otherwise falls back to the default `{platform}/{channel}/{title}.{ext}`.
*
* Backward-compatible: the 4-arg positional call still works identically.
*/
buildOutputPath(
platform: string,
channelName: string,
title: string,
ext: string
ext: string,
template?: string
): string {
const safeName = this.sanitizeFilename(channelName);
const safeTitle = this.sanitizeFilename(title);
const safeExt = ext.startsWith('.') ? ext.slice(1) : ext;
const now = new Date();
return path.join(this.mediaPath, platform, safeName, `${safeTitle}.${safeExt}`);
const vars: TemplateVars = {
platform,
channel: channelName,
title,
ext: safeExt,
date: now.toISOString().slice(0, 10), // YYYY-MM-DD
year: String(now.getFullYear()),
month: String(now.getMonth() + 1).padStart(2, '0'),
};
const resolved = this.resolveTemplate(template ?? DEFAULT_OUTPUT_TEMPLATE, vars);
return path.join(this.mediaPath, resolved);
}
/**
* Resolve a template string by replacing `{variable}` placeholders with
* sanitized values from `vars`. Path separators (`/`) in the template are
* preserved each segment between separators is sanitized independently.
*
* Unknown variables are left as-is (e.g. `{unknown}` stays `{unknown}`).
* Missing known variables resolve to empty string.
*/
resolveTemplate(template: string, vars: TemplateVars): string {
// Split on forward slash to handle each path segment
const segments = template.split('/');
const resolvedSegments = segments.map((segment) => {
// Replace all {var} tokens in this segment
const resolved = segment.replace(TEMPLATE_VAR_RE, (_match, varName: string) => {
if (!TEMPLATE_VARIABLES.includes(varName as TemplateVariable)) {
return `{${varName}}`; // Unknown variable — leave untouched
}
const value = vars[varName as TemplateVariable] ?? '';
// Don't sanitize ext — it's a bare token used after the dot
if (varName === 'ext') return value;
return this.sanitizeFilename(value);
});
return resolved;
});
return resolvedSegments.join('/');
}
/**
* Validate a template string for correctness.
*
* Rules:
* - Must contain `{ext}` (required for file extension).
* - Must not contain illegal filesystem characters outside of `{var}` placeholders.
* - All `{var}` names must be recognized template variables.
* - Must not be empty.
*/
validateTemplate(template: string): TemplateValidationResult {
const errors: string[] = [];
if (!template || template.trim().length === 0) {
return { valid: false, errors: ['Template must not be empty'] };
}
// Check for required {ext}
if (!template.includes('{ext}')) {
errors.push('Template must contain {ext} for the file extension');
}
// Check all variable references are recognized
const varMatches = [...template.matchAll(TEMPLATE_VAR_RE)];
for (const match of varMatches) {
const varName = match[1];
if (!TEMPLATE_VARIABLES.includes(varName as TemplateVariable)) {
errors.push(`Unknown template variable: {${varName}}`);
}
}
// Check for illegal characters outside variable placeholders
// Strip all {var} placeholders, then check what remains
const stripped = template.replace(TEMPLATE_VAR_RE, '');
if (ILLEGAL_PATH_CHARS.test(stripped)) {
errors.push('Template contains illegal filesystem characters');
}
return { valid: errors.length === 0, errors };
}
/**