From 71175198bd1c8f2ecb093476543326180ebb0c28 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 05:23:22 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Add=20resolveTemplate=20and=20validateT?= =?UTF-8?q?emplate=20methods=20to=20FileOrganizer=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/services/file-organizer.ts" - "src/__tests__/file-organizer.test.ts" GSD-Task: S02/T02 --- src/__tests__/file-organizer.test.ts | 228 ++++++++++++++++++++++++++- src/services/file-organizer.ts | 137 +++++++++++++++- 2 files changed, 356 insertions(+), 9 deletions(-) diff --git a/src/__tests__/file-organizer.test.ts b/src/__tests__/file-organizer.test.ts index c7313d2..ecc8f9e 100644 --- a/src/__tests__/file-organizer.test.ts +++ b/src/__tests__/file-organizer.test.ts @@ -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" ', + 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}//{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" ', + '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); + } + }); + }); }); diff --git a/src/services/file-organizer.ts b/src/services/file-organizer.ts index 36906d2..40f282e 100644 --- a/src/services/file-organizer.ts +++ b/src/services/file-organizer.ts @@ -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>; + +/** 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 }; } /**