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 { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
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;
|
let tmpDir: string;
|
||||||
|
|
||||||
|
|
@ -213,4 +213,230 @@ describe('FileOrganizer', () => {
|
||||||
expect(result).not.toMatch(/\\{2,}/);
|
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. */
|
/** Maximum attempts to find a unique filename. */
|
||||||
const MAX_UNIQUE_ATTEMPTS = 100;
|
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 ──
|
// ── FileOrganizer ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds structured output paths from content metadata and sanitizes
|
* Builds structured output paths from content metadata and sanitizes
|
||||||
* filenames for cross-platform safety.
|
* filenames for cross-platform safety. Supports configurable path
|
||||||
*
|
* templates with variables like {platform}, {channel}, {title}, {ext}.
|
||||||
* Path template: `{mediaPath}/{platform}/{channelName}/{title}.{ext}`
|
|
||||||
*/
|
*/
|
||||||
export class FileOrganizer {
|
export class FileOrganizer {
|
||||||
constructor(private readonly mediaPath: string) {}
|
constructor(private readonly mediaPath: string) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the full output path for a downloaded file.
|
* 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(
|
buildOutputPath(
|
||||||
platform: string,
|
platform: string,
|
||||||
channelName: string,
|
channelName: string,
|
||||||
title: string,
|
title: string,
|
||||||
ext: string
|
ext: string,
|
||||||
|
template?: string
|
||||||
): string {
|
): string {
|
||||||
const safeName = this.sanitizeFilename(channelName);
|
|
||||||
const safeTitle = this.sanitizeFilename(title);
|
|
||||||
const safeExt = ext.startsWith('.') ? ext.slice(1) : ext;
|
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