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:
parent
e6371ba196
commit
71175198bd
2 changed files with 356 additions and 9 deletions
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue