Compare commits

...

14 commits
v0.4.0 ... main

Author SHA1 Message Date
CDN
7ddaac6aba
chore: bump version
All checks were successful
Build and Release / Build (darwin-amd64) (push) Successful in 23s
Deploy docs / deploy (push) Successful in 45s
Build and Release / Build (linux-amd64) (push) Successful in 21s
Build and Release / Build (darwin-arm64) (push) Successful in 19s
Build and Release / Build (linux-arm64) (push) Successful in 18s
Build and Release / Build (windows-amd64) (push) Successful in 27s
Build and Release / Build (windows-arm64) (push) Successful in 17s
Build and Release / Create Release (push) Successful in 15s
2025-04-23 19:33:26 +08:00
CDN
6d730fa69b
docs: add ass docs 2025-04-23 19:33:02 +08:00
CDN
76e1298ded
chore: seperate large files 2025-04-23 19:22:41 +08:00
CDN
ebbf516689
feat: basic ass processing (without style) 2025-04-23 17:42:13 +08:00
CDN
8897d7ae90
docs: add notes about unstable behaviors 2025-04-23 16:37:12 +08:00
CDN
bcdcf598ea
chore: bump version
All checks were successful
Build and Release / Build (darwin-amd64) (push) Successful in 22s
Deploy docs / deploy (push) Successful in 44s
Build and Release / Build (linux-amd64) (push) Successful in 23s
Build and Release / Build (darwin-arm64) (push) Successful in 19s
Build and Release / Build (windows-amd64) (push) Successful in 22s
Build and Release / Build (linux-arm64) (push) Successful in 18s
Build and Release / Build (windows-arm64) (push) Successful in 17s
Build and Release / Create Release (push) Successful in 14s
2025-04-23 16:31:06 +08:00
CDN
bb87f058f0
feat: add tests 2025-04-23 16:30:45 +08:00
CDN
44c7e9bee5
chore: bump version
All checks were successful
Deploy docs / deploy (push) Successful in 44s
Build and Release / Build (windows-arm64) (push) Successful in 22s
Build and Release / Build (darwin-amd64) (push) Successful in 23s
Build and Release / Build (linux-amd64) (push) Successful in 22s
Build and Release / Build (darwin-arm64) (push) Successful in 18s
Build and Release / Build (windows-amd64) (push) Successful in 22s
Build and Release / Build (linux-arm64) (push) Successful in 18s
Build and Release / Create Release (push) Successful in 23s
2025-04-23 15:30:15 +08:00
CDN
2fa12dbcde
feat: support vtt in sync and fmt 2025-04-23 15:29:27 +08:00
CDN
a6284897c8
docs: tweak docs
All checks were successful
Build and Release / Build (darwin-arm64) (push) Successful in 18s
Build and Release / Build (darwin-amd64) (push) Successful in 22s
Build and Release / Build (linux-arm64) (push) Successful in 16s
Build and Release / Build (linux-amd64) (push) Successful in 18s
Build and Release / Build (windows-arm64) (push) Successful in 15s
Build and Release / Build (windows-amd64) (push) Successful in 21s
Build and Release / Create Release (push) Has been skipped
Deploy docs / deploy (push) Successful in 47s
enable cleanUrls and analytics
2025-04-23 14:32:52 +08:00
CDN
deae4a6272
docs: init docs
All checks were successful
Build and Release / Build (darwin-arm64) (push) Successful in 19s
Build and Release / Build (darwin-amd64) (push) Successful in 20s
Build and Release / Build (linux-arm64) (push) Successful in 17s
Build and Release / Build (linux-amd64) (push) Successful in 20s
Build and Release / Build (windows-arm64) (push) Successful in 16s
Build and Release / Build (windows-amd64) (push) Successful in 19s
Build and Release / Create Release (push) Has been skipped
Deploy docs / deploy (push) Successful in 45s
2025-04-23 14:16:16 +08:00
CDN
ba66894e42
feat: vtt converting 2025-04-23 10:44:08 +08:00
CDN
ba2e477dc0
feat: srt sync and formatting 2025-04-23 10:27:59 +08:00
CDN
6bb9f06c52
ci: also release windows-arm64 2025-04-23 08:07:49 +08:00
76 changed files with 11475 additions and 181 deletions

View file

@ -7,6 +7,7 @@ on:
- 'v*' - 'v*'
pull_request: pull_request:
branches: [ main ] branches: [ main ]
workflow_dispatch:
jobs: jobs:
build: build:
@ -66,5 +67,6 @@ jobs:
sub-cli-darwin-amd64/sub-cli-darwin-amd64 sub-cli-darwin-amd64/sub-cli-darwin-amd64
sub-cli-darwin-arm64/sub-cli-darwin-arm64 sub-cli-darwin-arm64/sub-cli-darwin-arm64
sub-cli-windows-amd64/sub-cli-windows-amd64.exe sub-cli-windows-amd64/sub-cli-windows-amd64.exe
sub-cli-windows-arm64/sub-cli-windows-arm64.exe
env: env:
GITHUB_TOKEN: ${{ secrets.ACTIONS_TOKEN }} GITHUB_TOKEN: ${{ secrets.ACTIONS_TOKEN }}

View file

@ -0,0 +1,42 @@
name: Deploy docs
on:
push:
branches:
- main
paths:
- docs/**
- .forgejo/workflows/docs.yml
workflow_dispatch:
jobs:
deploy:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: https://github.com/pnpm/action-setup@v4
with:
version: latest
- name: Build
run: |
cd docs
pnpm install
pnpm run docs:build
- name: Deploy to Remote
run: |
if [ ! -d ~/.ssh ]; then
mkdir -p ~/.ssh
fi
chmod 700 ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
dnf install -y rsync
rsync -av --delete -e "ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes -p ${{ secrets.SSH_PORT }}" docs/.vitepress/dist/ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:${{ secrets.WEB_ROOT }}/sub-cli
rm -rf ~/.ssh

View file

@ -6,7 +6,7 @@ See [releases](https://git.owu.one/starset-mirror/sub-cli/releases) for binaries
## Usage ## Usage
```shell ```shell
./sub-cli --help ./sub-cli help
``` ```
## License ## License

487
cmd/root_test.go Normal file
View file

@ -0,0 +1,487 @@
package cmd
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/config"
)
// setupTestEnv creates a testing environment with redirected stdout
// and returns the output buffer and cleanup function
func setupTestEnv() (*bytes.Buffer, func()) {
// Save original stdout
oldStdout := os.Stdout
// Create pipe to capture stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Create buffer to store output
outBuf := &bytes.Buffer{}
// Create cleanup function
cleanup := func() {
// Restore original stdout
os.Stdout = oldStdout
// Close writer
w.Close()
// Read from pipe
io.Copy(outBuf, r)
r.Close()
}
return outBuf, cleanup
}
// TestExecute_Version tests the version command
func TestExecute_Version(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args for version command
os.Args = []string{"sub-cli", "version"}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify output
expectedOutput := "sub-cli version " + config.Version
if !strings.Contains(output, expectedOutput) {
t.Errorf("Expected version output to contain '%s', got '%s'", expectedOutput, output)
}
}
// TestExecute_Help tests the help command
func TestExecute_Help(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args for help command
os.Args = []string{"sub-cli", "help"}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage:") {
t.Errorf("Expected help output to contain usage information")
}
if !strings.Contains(output, "Commands:") {
t.Errorf("Expected help output to contain commands information")
}
}
// TestExecute_NoArgs tests execution with no arguments
func TestExecute_NoArgs(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args with no command
os.Args = []string{"sub-cli"}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage:") {
t.Errorf("Expected output to contain usage information when no args provided")
}
}
// TestExecute_UnknownCommand tests execution with unknown command
func TestExecute_UnknownCommand(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args with unknown command
os.Args = []string{"sub-cli", "unknown-command"}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify output
if !strings.Contains(output, "Unknown command") {
t.Errorf("Expected output to contain 'Unknown command' message")
}
if !strings.Contains(output, "Usage:") {
t.Errorf("Expected output to contain usage information when unknown command provided")
}
}
// TestExecute_SyncCommand tests the sync command through Execute
func TestExecute_SyncCommand(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Create temporary test directory
tempDir := t.TempDir()
// Create source and target files
sourceFile := filepath.Join(tempDir, "source.lrc")
targetFile := filepath.Join(tempDir, "target.lrc")
if err := os.WriteFile(sourceFile, []byte("[00:01.00]Test line"), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
if err := os.WriteFile(targetFile, []byte("[00:10.00]Target line"), 0644); err != nil {
t.Fatalf("Failed to create target file: %v", err)
}
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args for sync command
os.Args = []string{"sub-cli", "sync", sourceFile, targetFile}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify no error message or expected error format
if strings.Contains(output, "Error:") && !strings.Contains(output, "Error: ") {
t.Errorf("Expected formatted error or no error, got: %s", output)
}
}
// TestExecute_ConvertCommand tests the convert command through Execute
func TestExecute_ConvertCommand(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Create temporary test directory
tempDir := t.TempDir()
// Create source file
sourceContent := `1
00:00:01,000 --> 00:00:04,000
This is a test subtitle.`
sourceFile := filepath.Join(tempDir, "source.srt")
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
// Define target file
targetFile := filepath.Join(tempDir, "target.lrc")
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args for convert command
os.Args = []string{"sub-cli", "convert", sourceFile, targetFile}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify no error message
if strings.Contains(output, "Error:") {
t.Errorf("Expected no error, but got: %s", output)
}
// Verify target file exists
if _, err := os.Stat(targetFile); os.IsNotExist(err) {
t.Errorf("Target file was not created")
}
}
// TestHandleSync tests the sync command
func TestHandleSync(t *testing.T) {
// Create temporary test directory
tempDir := t.TempDir()
// Create source file
sourceContent := `[00:01.00]This is line one.
[00:05.00]This is line two.`
sourceFile := filepath.Join(tempDir, "source.lrc")
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
// Create target file
targetContent := `[00:10.00]This is target line one.
[00:20.00]This is target line two.`
targetFile := filepath.Join(tempDir, "target.lrc")
if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil {
t.Fatalf("Failed to create target file: %v", err)
}
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute sync command
handleSync([]string{sourceFile, targetFile})
// Get output
cleanup()
output := outBuf.String()
// Verify no error message
if strings.Contains(output, "Error:") {
t.Errorf("Expected no error, but got: %s", output)
}
// Verify target file has been modified
modifiedContent, err := os.ReadFile(targetFile)
if err != nil {
t.Fatalf("Failed to read modified target file: %v", err)
}
// Check that target file now has source timings
if !strings.Contains(string(modifiedContent), "[00:01.000]") {
t.Errorf("Expected modified target to contain source timing [00:01.000], got: %s", string(modifiedContent))
}
// Check that target content is preserved
if !strings.Contains(string(modifiedContent), "This is target line one.") {
t.Errorf("Expected modified target to preserve content 'This is target line one.', got: %s", string(modifiedContent))
}
}
// TestHandleSync_NoArgs tests sync command with insufficient arguments
func TestHandleSync_NoArgs(t *testing.T) {
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute sync command with no args
handleSync([]string{})
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage: sub-cli sync") {
t.Errorf("Expected sync usage information when no args provided")
}
}
// TestHandleSync_OneArg tests sync command with only one argument
func TestHandleSync_OneArg(t *testing.T) {
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute sync command with one arg
handleSync([]string{"source.lrc"})
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage: sub-cli sync") {
t.Errorf("Expected sync usage information when only one arg provided")
}
}
// TestHandleConvert tests the convert command
func TestHandleConvert(t *testing.T) {
// Create temporary test directory
tempDir := t.TempDir()
// Create source file
sourceContent := `1
00:00:01,000 --> 00:00:04,000
This is a test subtitle.`
sourceFile := filepath.Join(tempDir, "source.srt")
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
// Define target file
targetFile := filepath.Join(tempDir, "target.vtt")
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute convert command
handleConvert([]string{sourceFile, targetFile})
// Get output
cleanup()
output := outBuf.String()
// Verify no error message
if strings.Contains(output, "Error:") {
t.Errorf("Expected no error, but got: %s", output)
}
// Verify target file has been created
if _, err := os.Stat(targetFile); os.IsNotExist(err) {
t.Errorf("Target file was not created")
}
// Verify target file content
targetContent, err := os.ReadFile(targetFile)
if err != nil {
t.Fatalf("Failed to read target file: %v", err)
}
// Check that target file has VTT format
if !strings.Contains(string(targetContent), "WEBVTT") {
t.Errorf("Expected target file to have WEBVTT header, got: %s", string(targetContent))
}
// Check that content is preserved
if !strings.Contains(string(targetContent), "This is a test subtitle.") {
t.Errorf("Expected target file to preserve content, got: %s", string(targetContent))
}
}
// TestHandleConvert_NoArgs tests convert command with insufficient arguments
func TestHandleConvert_NoArgs(t *testing.T) {
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute convert command with no args
handleConvert([]string{})
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage: sub-cli convert") {
t.Errorf("Expected convert usage information when no args provided")
}
}
// TestHandleFormat tests the fmt command
func TestHandleFormat(t *testing.T) {
// Create temporary test directory
tempDir := t.TempDir()
// Create test file with non-sequential numbers
content := `2
00:00:05,000 --> 00:00:08,000
This is the second line.
1
00:00:01,000 --> 00:00:04,000
This is the first line.`
testFile := filepath.Join(tempDir, "test.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute fmt command
handleFormat([]string{testFile})
// Get output
cleanup()
output := outBuf.String()
// Verify no error message
if strings.Contains(output, "Error:") {
t.Errorf("Expected no error, but got: %s", output)
}
// Verify file has been modified
modifiedContent, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read modified file: %v", err)
}
// Check that entries are correctly numbered - don't assume ordering by timestamp
contentStr := string(modifiedContent)
// Just check that identifiers 1 and 2 exist and content is preserved
if !strings.Contains(contentStr, "1\n00:00:") && !strings.Contains(contentStr, "2\n00:00:") {
t.Errorf("Output should contain sequential identifiers (1 and 2)")
}
// Check content preservation
if !strings.Contains(contentStr, "This is the first line.") ||
!strings.Contains(contentStr, "This is the second line.") {
t.Errorf("Output should preserve all content")
}
}
// TestHandleFormat_NoArgs tests fmt command with no arguments
func TestHandleFormat_NoArgs(t *testing.T) {
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute fmt command with no args
handleFormat([]string{})
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage: sub-cli fmt") {
t.Errorf("Expected fmt usage information when no args provided")
}
}
// TestHandleFormat_Error tests the error path in handleFormat
func TestHandleFormat_Error(t *testing.T) {
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute format command with non-existent file
nonExistentFile := "/non/existent/path.srt"
handleFormat([]string{nonExistentFile})
// Get output
cleanup()
output := outBuf.String()
// Verify error message is printed
if !strings.Contains(output, "Error:") {
t.Errorf("Expected error message for non-existent file, got: %s", output)
}
}

5
docs/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Vitepress GitIgnore
.vitepress/cache/
.vitepress/dist/
node_modules/

View file

@ -0,0 +1,68 @@
import { defineConfig } from 'vitepress'
import { zhHansThemeConfig } from '../zh-Hans/config'
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "Sub-CLI",
description: "The Subtitle Manipulation CLI",
locales: {
root: {
label: 'English',
lang: 'en'
},
'zh-Hans': {
label: '简体中文',
lang: 'zh-Hans',
themeConfig: zhHansThemeConfig
},
},
cleanUrls: true,
head: [
[
'script',
{
defer: '',
src: 'https://analytics.owu.one/script.js',
'data-website-id': '2ed09e92-68ce-422b-a949-0feb210c9d31'
}
]
],
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
nav: [
{ text: 'Home', link: '/' },
],
sidebar: [
{
text: 'Introduction',
items: [
{ text: 'Getting Started', link: '/getting-started' },
{ text: 'Installation Guide', link: '/installation' }
]
},
{
text: 'Usage',
items: [
{ text: 'Command Examples', link: '/examples' },
{ text: 'Command Reference', link: '/commands' }
]
},
{
text: 'Project',
items: [
{ text: 'Provide Feedback', link: '/feedback' }
]
}
],
editLink: {
pattern: 'https://git.owu.one/wholetrans/sub-cli/edit/main/docs/:path',
text: 'Edit on Owu Git'
},
socialLinks: [
{ icon: 'forgejo', link: 'https://git.owu.one/wholetrans/sub-cli' }
]
},
})

304
docs/commands.md Normal file
View file

@ -0,0 +1,304 @@
---
title: Command Reference
description: Detailed documentation for all Sub-CLI commands
---
# Command Reference
This page provides detailed documentation for all available Sub-CLI commands, their options, and usage.
## Global Options
These options are available across all Sub-CLI commands:
```
help Display help information for a command
```
## convert
The `convert` command transforms subtitle files between different formats, preserving as much information as possible while adapting to the target format's capabilities.
### Usage
```
sub-cli convert <source> <target>
```
### Arguments
| Argument | Description |
|----------|-------------|
| `<source>` | Path to the source subtitle file |
| `<target>` | Path to the target subtitle file to be created |
### Supported Format Conversions
| Source Format | Target Format | Notes |
|---------------|---------------|-------|
| SRT (.srt) | SRT, VTT, LRC, TXT, ASS | - |
| VTT (.vtt) | SRT, VTT, LRC, TXT, ASS | - |
| LRC (.lrc) | SRT, VTT, LRC, TXT, ASS | - |
| ASS (.ass) | SRT, VTT, LRC, TXT, ASS | - |
| TXT (.txt) | — | TXT can only be a target format, not a source format |
### Feature Preservation
The conversion process aims to preserve as many features as possible, but some format-specific features may be lost or adapted:
#### SRT Features
- **Preserved**: Text content, timeline (start and end times), basic styling (bold, italic, underline)
- **Lost in some formats**: HTML styling tags when converting to formats like LRC or TXT
#### VTT Features
- **Preserved**: Text content, timeline, title, CSS styling (when target supports it)
- **Lost in some formats**: Positioning, alignment, and advanced styling when converting to SRT or LRC
#### LRC Features
- **Preserved**: Text content, timeline, metadata (title, artist, album)
- **Structure limitation**: LRC only supports start timestamps (no end timestamps), unlike SRT and VTT
- **Adapted when converting from LRC**: When converting to SRT/VTT, the single timestamp per line in LRC is converted to start+end time pairs. End times are calculated by:
- Using the next entry's start time as the current entry's end time
- For the last entry, a default duration (typically 3-5 seconds) is added to create an end time
- **Lost when converting to LRC**: When other formats are converted to LRC, any end timestamp information is discarded
#### ASS Features
- **Preserved**: Text content, timeline (start and end times), basic styling information
- **Minimalist approach**: Conversion creates a "minimal" ASS file with essential structure
- **When converting to ASS**:
- Basic styles (bold, italic, underline) are converted to ASS styles with default settings
- Default font is Arial, size 20pt with standard colors and margins
- Only "Dialogue" events are created (not "Comment" or other event types)
- **When converting from ASS**:
- Only "Dialogue" events are converted, "Comment" events are ignored
- Style information is preserved where the target format supports it
- ASS-specific attributes (Layer, MarginL/R/V, etc.) are stored as metadata when possible
#### TXT Features
- **Output only**: Plain text format contains only the text content without any timing or styling
### Technical Details
The converter uses an intermediate representation that attempts to preserve as much format-specific data as possible. The conversion happens in two steps:
1. Convert source format to intermediate representation
2. Convert intermediate representation to target format
This approach minimizes information loss and ensures the most accurate conversion possible.
### Examples
```bash
# Convert from SRT to WebVTT
sub-cli convert subtitles.srt subtitles.vtt
# Convert from LRC to plain text (strips timing info)
sub-cli convert lyrics.lrc transcript.txt
# Convert from WebVTT to SRT
sub-cli convert subtitles.vtt subtitles.srt
# Convert from SRT to ASS
sub-cli convert subtitles.srt subtitles.ass
# Convert from ASS to SRT
sub-cli convert subtitles.ass subtitles.srt
```
## sync
The `sync` command applies the timing/timestamps from a source subtitle file to a target subtitle file while preserving the target file's content.
### Usage
```
sub-cli sync <source> <target>
```
### Arguments
| Argument | Description |
|----------|-------------|
| `<source>` | Path to the source subtitle file with the reference timeline |
| `<target>` | Path to the target subtitle file to be synchronized |
### Supported Formats
Currently, synchronization only works between files of the same format:
- SRT to SRT
- LRC to LRC
- VTT to VTT
- ASS to ASS
### Behavior Details
#### For LRC Files:
- **When entry counts match**: The source timeline is directly applied to the target content.
- **When entry counts differ**: The source timeline is scaled to match the target content using linear interpolation:
- For each target entry position, a corresponding position in the source timeline is calculated
- Times are linearly interpolated between the nearest source entries
- This ensures smooth and proportional timing distribution across varying entry counts
- **Preserved from target**: All content text and metadata (artist, title, etc.).
- **Modified in target**: Only timestamps are updated.
#### For SRT Files:
- **When entry counts match**: Both start and end times from the source are directly applied to the target entries.
- **When entry counts differ**: A scaled approach using linear interpolation is used:
- Start times are calculated using linear interpolation between source entries
- End times are calculated based on source entry durations
- The time relationships between entries are preserved
- **Preserved from target**: All content text.
- **Modified in target**: Timestamps are updated and cue identifiers are standardized (sequential from 1).
#### For VTT Files:
- **When entry counts match**: Both start and end times from the source are directly applied to the target entries.
- **When entry counts differ**: A scaled approach using linear interpolation is used:
- Start times are calculated using linear interpolation between source entries
- End times are calculated based on source entry durations
- The time relationships between entries are preserved
- **Preserved from target**: All subtitle text content and styling information.
- **Modified in target**: Timestamps are updated and cue identifiers are standardized.
#### For ASS Files:
- **When entry counts match**: The source timeline (start and end times) is directly applied to the target events.
- **When entry counts differ**: A scaled approach using linear interpolation is used:
- Start times are calculated using linear interpolation between source events
- End times are calculated based on source event durations
- The time relationships between events are preserved
- **Preserved from target**: All event text content, style references, and other attributes like Layer, MarginL/R/V.
- **Modified in target**: Only the timestamps (Start and End) are updated.
### Timeline Interpolation Details
The sync command uses linear interpolation to handle different entry counts between source and target files:
- **What is linear interpolation?** It's a mathematical technique for estimating values between two known points. For timeline synchronization, it creates a smooth transition between source timestamps when applied to a different number of target entries.
- **How it works:**
1. The algorithm maps each target entry position to a corresponding position in the source timeline
2. For each target position, it calculates a timestamp by interpolating between the nearest source timestamps
3. The calculation ensures proportionally distributed timestamps that maintain the rhythm of the original
- **Example:** If source file has entries at 1s, 5s, and 9s (3 entries), and target has 5 entries, the interpolated timestamps would be approximately 1s, 3s, 5s, 7s, and 9s, maintaining even spacing.
- **Benefits of linear interpolation:**
- More accurate timing when entry counts differ significantly
- Preserves the pacing and rhythm of the source timeline
- Handles both expanding (target has more entries) and contracting (target has fewer entries) scenarios
### Edge Cases
- If the source file has no timing information, the target remains unchanged.
- If source duration calculations result in negative values, a default duration of zero is applied (improved from previous 3-second default).
- The command displays a warning when entry counts differ but proceeds with the scaled synchronization.
- Format-specific features from the target file (such as styling, alignment, metadata) are preserved. The sync operation only replaces timestamps, not any other formatting or content features.
### Examples
```bash
# Synchronize an SRT file using another SRT file as reference
sub-cli sync reference.srt target.srt
# Synchronize an LRC file using another LRC file as reference
sub-cli sync reference.lrc target.lrc
# Synchronize a VTT file using another VTT file as reference
sub-cli sync reference.vtt target.vtt
# Synchronize an ASS file using another ASS file as reference
sub-cli sync reference.ass target.ass
```
## fmt
The `fmt` command standardizes and formats subtitle files according to their format-specific conventions.
### Usage
```
sub-cli fmt <file>
```
### Arguments
| Argument | Description |
|----------|-------------|
| `<file>` | Path to the subtitle file to format |
### Supported Formats
| Format | Extension | Formatting Actions |
|--------|-----------|-------------------|
| SRT | `.srt` | Standardizes entry numbering (sequential from 1)<br>Formats timestamps in `00:00:00,000` format<br>Ensures proper spacing between entries |
| LRC | `.lrc` | Organizes metadata tags<br>Standardizes timestamp format `[mm:ss.xx]`<br>Ensures proper content alignment |
| VTT | `.vtt` | Validates WEBVTT header<br>Standardizes cue identifiers<br>Formats timestamps in `00:00:00.000` format<br>Organizes styling information |
| ASS | `.ass` | Standardizes section order ([Script Info], [V4+ Styles], [Events])<br>Formats timestamps in `h:mm:ss.cc` format<br>Preserves all script info, styles and event data |
### Format-Specific Details
#### SRT Formatting
The formatter parses the SRT file, extracts all entries, ensures sequential numbering from 1, and writes the file back with consistent formatting. This preserves all content and timing information while standardizing the structure.
#### LRC Formatting
For LRC files, the formatter preserves all metadata and content but standardizes the format of timestamps and ensures proper alignment. This makes the file easier to read and more compatible with different LRC parsers.
#### VTT Formatting
When formatting WebVTT files, the command ensures proper header format, sequential cue identifiers, and standard timestamp formatting. All VTT-specific features like styling, positioning, and comments are preserved.
#### ASS Formatting
The formatter reads and parses the ASS file, then regenerates it with standardized structure. It maintains all original content, including script information, styles, and events. The standard section order ([Script Info], [V4+ Styles], [Events]) is enforced, and timestamps are formatted in the standard `h:mm:ss.cc` format.
### Examples
```bash
# Format an SRT file
sub-cli fmt subtitles.srt
# Format an LRC file
sub-cli fmt lyrics.lrc
# Format a VTT file
sub-cli fmt subtitles.vtt
# Format an ASS file
sub-cli fmt subtitles.ass
```
## version
Displays the current version of Sub-CLI.
### Usage
```
sub-cli version
```
## help
Displays general help information or help for a specific command.
### Usage
```
sub-cli help [command]
```
### Arguments
| Argument | Description |
|----------|-------------|
| `[command]` | (Optional) Specific command to get help for |
### Examples
```bash
# Display general help
sub-cli help
# Display help for the convert command
sub-cli help convert
```

63
docs/examples.md Normal file
View file

@ -0,0 +1,63 @@
---
title: Command Examples
description: Practical examples of Sub-CLI commands in action
---
# Command Examples
This page provides practical examples of Sub-CLI commands for common subtitle manipulation tasks.
## Format Conversion Examples
Convert between various subtitle formats:
```bash
# Convert from SRT to WebVTT
sub-cli convert subtitles.srt subtitles.vtt
# Convert from LRC to SRT
sub-cli convert lyrics.lrc subtitles.srt
# Convert from WebVTT to plain text (stripping timestamps)
sub-cli convert subtitles.vtt plain_text.txt
# Convert from SRT to LRC
sub-cli convert subtitles.srt lyrics.lrc
```
## Synchronization Examples
Synchronize timelines between subtitle files:
```bash
# Synchronize an SRT file using another SRT file as reference
sub-cli sync reference.srt target.srt
# Synchronize an LRC file using another LRC file as reference
sub-cli sync reference.lrc target.lrc
```
Note: Synchronization works between files of the same format. If the number of entries differs between source and target files, Sub-CLI will display a warning and scale the timeline appropriately.
## Formatting Examples
Format subtitle files for consistent styling:
```bash
# Format an SRT file
sub-cli fmt subtitles.srt
# Format an LRC file
sub-cli fmt lyrics.lrc
# Format a WebVTT file
sub-cli fmt subtitles.vtt
```
Formatting ensures:
- Sequential entry numbering
- Consistent timestamp formatting
- Proper spacing and line breaks
- Format-specific standard compliance
These examples demonstrate the versatility of Sub-CLI for handling various subtitle manipulation tasks. For detailed information on each command and all available options, see the [Command Reference](/commands) page.

82
docs/feedback.md Normal file
View file

@ -0,0 +1,82 @@
---
title: Provide Feedback
description: Help improve Sub-CLI by sharing your experience and suggestions
---
# Provide Feedback
Your feedback is invaluable to the continued development and improvement of Sub-CLI. We welcome all types of feedback, including bug reports, feature requests, usability suggestions, and general comments.
## Ways to Provide Feedback
### Issues
The best way to report bugs or request features is through our issue tracker:
1. Visit the [Sub-CLI Issues](https://git.owu.one/wholetrans/sub-cli/issues) page
2. Click on "New Issue"
3. Provide as much detail as possible
4. Submit the issue
### Email
If you prefer, you can send feedback directly via email to:
`hello@wholetrans.org` (example email)
### Community Channels
Join our community to discuss Sub-CLI, share your experience, and get help:
::: info
Currently there's no dedicated channel for Sub-CLI. You can join our `#general` room for general questions and discussions.
:::
- Matrix Space: [#wholetrans:mtx.owu.one](https://matrix.to/room/#wholetrans:mtx.owu.one)
You can find more contact information in our [About](https://wholetrans.org/about) page.
## Reporting Bugs
When reporting bugs, please include:
1. **Sub-CLI Version**: Output of `sub-cli version`
2. **Operating System**: Your OS name and version
3. **Steps to Reproduce**: Detailed steps to reproduce the issue
4. **Expected Behavior**: What you expected to happen
5. **Actual Behavior**: What actually happened
6. **Additional Context**: Any other relevant information, such as command output, error messages, or screenshots
## Feature Requests
When requesting new features, please include:
1. **Use Case**: Describe the specific scenario or problem you're trying to solve
2. **Proposed Solution**: Your idea for implementing the feature
3. **Alternatives Considered**: Any alternative solutions you've considered
4. **Additional Context**: Any other relevant information that might help us understand the request
## Contribution Guidelines
Interested in contributing to Sub-CLI? We welcome contributions of all kinds, from code improvements to documentation updates.
### Code Contributions
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
### Documentation Contributions
Found an error or omission in our documentation? Have an idea for improvement? We welcome:
- Documentation fixes and improvements
- Examples and tutorials
## Thank You!
Your feedback and contributions help make Sub-CLI better for everyone. We appreciate your time and effort in helping us improve the tool.

56
docs/getting-started.md Normal file
View file

@ -0,0 +1,56 @@
---
title: Getting Started
description: Introduction to the Sub-CLI tool and its capabilities
---
# Getting Started with Sub-CLI
::: info Current Status
We've in the process of building basic features of Sub-CLI. Behavior may be unstable.
:::
Sub-CLI is a command-line tool designed for subtitle manipulation and generation. Whether you need to convert subtitle formats, synchronize timelines, format subtitle files, Sub-CLI provides a robust set of features for all your subtitle needs.
## What Can Sub-CLI Do?
- **Convert** between various subtitle formats (SRT, VTT, LRC, ASS, TXT)
- **Synchronize** timelines between subtitle files
- **Format** subtitle files to ensure consistent styling
## Key Features
- **Format Flexibility**: Support for multiple subtitle formats including SRT, VTT, LRC, ASS, and plain text
- **Timeline Synchronization**: Easily align subtitles with audio/video content
- **Format-Specific Feature Preservation**: Maintains format-specific features during conversion
- **Clean Command Interface**: Simple, intuitive commands for efficient workflow
## Quick Navigation
Ready to dive in? Here's where to go next:
- [Installation Guide](/installation) - Download and set up Sub-CLI on your system
- [Command Examples](/examples) - See practical examples of Sub-CLI in action
- [Command Reference](/commands) - Detailed documentation for all available commands
- [Provide Feedback](/feedback) - Help us improve Sub-CLI by sharing your experience
## Basic Usage
Once installed, you can start using Sub-CLI with simple commands like:
```bash
# Convert a subtitle from one format to another
sub-cli convert input.srt output.vtt
# Synchronize timelines between two subtitle files
sub-cli sync source.srt target.srt
# Format a subtitle file
sub-cli fmt subtitle.srt
# Convert to ASS format
sub-cli convert input.srt output.ass
```
Check out the [Command Examples](/examples) page for more detailed usage scenarios.

27
docs/index.md Normal file
View file

@ -0,0 +1,27 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: "Sub-CLI"
# text: "The Subtitle Manipulation CLI"
tagline: "The Subtitle Manipulation CLI"
actions:
- theme: brand
text: Getting Started
link: /getting-started
- theme: alt
text: Command Reference
link: /commands
features:
- title: One-Stop Workflow
details: (WIP) From raw audio/video to multi-language, styled subtitles
- title: Interactive and Automated
details: (Coming Soon) Integrate to your workflow with pre-configured arguments, or simply launch a TUI and tune your settings
- title: Batch Processing
details: (Coming Soon) Process multiple files utilizing every device you have at your desired concurrency
- title: Out of the Box Integration
details: (Coming Soon) Choose from various providers with one account when you prefer cloud processing
---

107
docs/installation.md Normal file
View file

@ -0,0 +1,107 @@
---
title: Installation Guide
description: How to download and install Sub-CLI on your system
---
# Installation Guide
Follow these simple steps to get Sub-CLI up and running on your computer.
## Download the Right Version
Sub-CLI is available for various operating systems and architectures. Visit the [Releases](https://git.owu.one/wholetrans/sub-cli/releases) page to download the appropriate version for your system.
### Understanding Which Version to Download
The release files are named following this pattern:
```
sub-cli-[OS]-[ARCHITECTURE][.exe]
```
Where:
- **OS** is your operating system (windows, darwin, linux)
- **ARCHITECTURE** is your computer's processor type (amd64, arm64)
- The `.exe` extension is only present for Windows versions
### Which Version Do I Need?
Here's a simple guide to help you choose:
| Operating System | Processor Type | Download File |
|------------------|----------------|---------------|
| Windows | Intel/AMD (most PCs) | `sub-cli-windows-amd64.exe` |
| Windows | ARM (Surface Pro X, etc.) | `sub-cli-windows-arm64.exe` |
| macOS | Intel Mac | `sub-cli-darwin-amd64` |
| macOS | Apple Silicon (M series processors) | `sub-cli-darwin-arm64` |
| Linux | Intel/AMD (most PCs/servers) | `sub-cli-linux-amd64` |
| Linux | ARM (Raspberry Pi, ARM-based VPS, etc.) | `sub-cli-linux-arm64` |
If you're unsure about your system architecture, most modern computers use amd64 (also known as x86_64) architecture.
## Installation Steps
::: tip
For temporary use, place the sub-cli binary in the current directory or any location you prefer, without adding it to the PATH.
:::
### Windows
1. Download the appropriate `.exe` file from the Releases page
2. Move the file to a location of your choice (e.g., `C:\Users\[username]\bin\`)
3. (Optional) Add the folder to your PATH environment variable to run Sub-CLI from any location:
- Right-click on "This PC" and select "Properties"
- Click on "Advanced system settings"
- Click on "Environment Variables"
- Under "System variables", find the "Path" variable, select it and click "Edit"
- Click "New" and add the path to the folder containing the Sub-CLI executable
- Click "OK" on all dialog boxes to save the changes
### macOS (Darwin)
1. Download the appropriate file from the Releases page
2. Open Terminal
3. Make the file executable with the command:
```bash
chmod +x path/to/downloaded/sub-cli-darwin-[architecture]
```
4. (Optional) Move it to a location in your PATH for easier access:
```bash
sudo mv path/to/downloaded/sub-cli-darwin-[architecture] ~/.local/bin/sub-cli
```
### Linux
1. Download the appropriate file from the Releases page
2. Open Terminal
3. Make the file executable with the command:
```bash
chmod +x path/to/downloaded/sub-cli-linux-[architecture]
```
4. (Optional) Move it to a location in your PATH for easier access:
```bash
sudo mv path/to/downloaded/sub-cli-linux-[architecture] ~/.local/bin/sub-cli
```
## Verifying Installation
To verify that Sub-CLI is installed correctly, open a command prompt or terminal and run:
```bash
sub-cli version
```
You should see the current version number of Sub-CLI displayed.
## Troubleshooting
If you encounter any issues during installation:
- Make sure you've downloaded the correct version for your operating system and architecture
- Ensure the file has executable permissions (on macOS and Linux)
- Verify that the file is in a location accessible by your command prompt or terminal
- If you've added it to PATH, try restarting your command prompt or terminal
For further assistance, please visit our [feedback page](/feedback) to report the issue.

11
docs/package.json Normal file
View file

@ -0,0 +1,11 @@
{
"devDependencies": {
"vitepress": "^2.0.0-alpha.5"
},
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6",
"scripts": {
"docs:dev": "vitepress dev .",
"docs:build": "vitepress build .",
"docs:preview": "vitepress preview ."
}
}

1596
docs/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

304
docs/zh-Hans/commands.md Normal file
View file

@ -0,0 +1,304 @@
---
title: 命令参考
description: 所有Sub-CLI命令的详细文档
---
# 命令参考
本页面提供了所有可用Sub-CLI命令的详细文档、选项和用法。
## 全局选项
这些选项在所有Sub-CLI命令中都可用
```
help 显示命令的帮助信息
```
## convert
`convert` 命令将字幕文件在不同格式之间转换,尽可能保留信息,同时适应目标格式的功能。
### 用法
```
sub-cli convert <源文件> <目标文件>
```
### 参数
| 参数 | 描述 |
|----------|-------------|
| `<源文件>` | 源字幕文件的路径 |
| `<目标文件>` | 要创建的目标字幕文件的路径 |
### 支持的格式转换
| 源格式 | 目标格式 | 注意 |
|---------------|---------------|-------|
| SRT (.srt) | SRT, VTT, LRC, TXT, ASS | - |
| VTT (.vtt) | SRT, VTT, LRC, TXT, ASS | - |
| LRC (.lrc) | SRT, VTT, LRC, TXT, ASS | - |
| ASS (.ass) | SRT, VTT, LRC, TXT, ASS | - |
| TXT (.txt) | — | TXT只能作为目标格式不能作为源格式 |
### 功能保留
转换过程旨在尽可能保留更多功能,但某些特定于格式的功能可能会丢失或适应:
#### SRT功能
- **保留**: 文本内容、时间线(开始和结束时间)、基本样式(粗体、斜体、下划线)
- **在某些格式中丢失**: 转换为LRC或TXT等格式时的HTML样式标签
#### VTT功能
- **保留**: 文本内容、时间线、标题、CSS样式当目标格式支持时
- **在某些格式中丢失**: 转换为SRT或LRC时的定位、对齐和高级样式
#### LRC功能
- **保留**: 文本内容、时间线、元数据(标题、艺术家、专辑)
- **结构限制**: LRC只支持开始时间戳没有结束时间戳不像SRT和VTT
- **从LRC转换时的适应**: 当转换为SRT/VTT时LRC中每行的单一时间戳会转换为开始+结束时间对。结束时间的计算方式为:
- 使用下一个条目的开始时间作为当前条目的结束时间
- 对于最后一个条目添加默认时长通常3-5秒来创建结束时间
- **转换为LRC时丢失**: 当其他格式转换为LRC时任何结束时间戳信息都会被丢弃
#### ASS功能
- **保留**: 文本内容、时间线(开始和结束时间)、基本样式信息
- **仅有基本支持**: 转换创建一个具有基本结构的"最小"ASS文件
- **转换为ASS时**:
- 基本样式粗体、斜体、下划线会转换为具有默认设置的ASS样式
- 默认字体为Arial大小20pt具有标准颜色和边距
- 只创建"Dialogue"(对话)类型的事件(不创建"Comment"或其他事件类型)
- **从ASS转换时**:
- 只转换类型为"Dialogue"的事件,忽略"Comment"事件
- 在目标格式支持的情况下保留样式信息
- ASS特有的属性如Layer、MarginL/R/V等在可能的情况下存储为元数据
#### TXT功能
- **仅输出**: 纯文本格式只包含没有任何时间或样式的文本内容
### 技术细节
转换器使用中间表示法,尽可能保留特定格式的数据。转换分两步进行:
1. 将源格式转换为中间表示
2. 将中间表示转换为目标格式
这种方法最大限度地减少信息丢失,确保尽可能准确的转换。
### 示例
```bash
# 从SRT转换为WebVTT
sub-cli convert subtitles.srt subtitles.vtt
# 从LRC转换为纯文本去除时间信息
sub-cli convert lyrics.lrc transcript.txt
# 从WebVTT转换为SRT
sub-cli convert subtitles.vtt subtitles.srt
# 从SRT转换为ASS
sub-cli convert subtitles.srt subtitles.ass
# 从ASS转换为SRT
sub-cli convert subtitles.ass subtitles.srt
```
## sync
`sync` 命令将源字幕文件的时间/时间戳应用到目标字幕文件,同时保留目标文件的内容。
### 用法
```
sub-cli sync <源文件> <目标文件>
```
### 参数
| 参数 | 描述 |
|----------|-------------|
| `<源文件>` | 带有参考时间线的源字幕文件的路径 |
| `<目标文件>` | 要同步的目标字幕文件的路径 |
### 支持的格式
目前,同步仅适用于相同格式的文件之间:
- SRT到SRT
- LRC到LRC
- VTT到VTT
- ASS到ASS
### 行为详情
#### 对于LRC文件
- **当条目数匹配时**: 源时间线直接应用于目标内容。
- **当条目数不同时**: 源时间线使用线性插值进行缩放以匹配目标内容:
- 对于每个目标条目位置,计算源时间线中的对应位置
- 在最近的源条目之间进行线性插值计算时间
- 这确保了在不同数量的条目间实现平滑和比例化的时间分布
- **从目标保留**: 所有内容文本和元数据(艺术家、标题等)。
- **在目标中修改**: 只更新时间戳。
#### 对于SRT文件
- **当条目数匹配时**: 源的开始和结束时间直接应用于目标条目。
- **当条目数不同时**: 使用基于线性插值的缩放方法:
- 开始时间使用源条目之间的线性插值计算
- 结束时间根据源条目时长计算
- 保持条目之间的时间关系
- **从目标保留**: 所有内容文本。
- **在目标中修改**: 更新时间戳并标准化条目编号从1开始顺序编号
#### 对于VTT文件
- **当条目数匹配时**: 源的开始和结束时间直接应用于目标条目。
- **当条目数不同时**: 使用基于线性插值的缩放方法:
- 开始时间使用源条目之间的线性插值计算
- 结束时间根据源条目时长计算
- 保持条目之间的时间关系
- **从目标保留**: 所有字幕文本内容和样式信息。
- **在目标中修改**: 更新时间戳并标准化提示标识符。
#### 对于ASS文件
- **当条目数匹配时**: 源时间线(开始和结束时间)直接应用于目标事件。
- **当条目数不同时**: 使用基于线性插值的缩放方法:
- 开始时间使用源事件之间的线性插值计算
- 结束时间根据源事件时长计算
- 保持事件之间的时间关系
- **从目标保留**: 所有事件文本内容、样式引用和其他属性如Layer、MarginL/R/V
- **在目标中修改**: 只更新时间戳Start和End
### 时间线插值详情
同步命令使用线性插值来处理源文件和目标文件之间不同的条目数量:
- **什么是线性插值?** 这是一种估计两个已知点之间值的数学技术。对于时间线同步,它在应用于不同数量的目标条目时,可以在源时间戳之间创建平滑过渡。
- **工作原理:**
1. 算法将每个目标条目位置映射到源时间线中的对应位置
2. 对于每个目标位置,通过插值计算最近的源时间戳之间的时间戳
3. 计算确保按比例分布的时间戳,保持原始节奏
- **示例:** 如果源文件在1秒、5秒和9秒有条目共3个条目而目标有5个条目插值后的时间戳将大约为1秒、3秒、5秒、7秒和9秒保持均匀间隔。
- **线性插值的好处:**
- 当条目数相差很大时,提供更准确的时间
- 保持源时间线的节奏和韵律
- 既能处理扩展(目标条目更多)也能处理收缩(目标条目更少)的情况
### 边缘情况
- 如果源文件没有时间信息,目标保持不变。
- 如果源时长计算导致负值会应用默认的零秒时长改进自之前的3秒默认值
- 当条目数不同时,命令会显示警告但会继续进行缩放同步。
- 目标文件中的特定格式功能(如样式、对齐方式、元数据)会被保留。同步操作只替换时间戳,不会更改任何其他格式或内容功能。
### 示例
```bash
# 使用另一个SRT文件作为参考来同步SRT文件
sub-cli sync reference.srt target.srt
# 使用另一个LRC文件作为参考来同步LRC文件
sub-cli sync reference.lrc target.lrc
# 使用另一个VTT文件作为参考来同步VTT文件
sub-cli sync reference.vtt target.vtt
# 使用另一个ASS文件作为参考来同步ASS文件
sub-cli sync reference.ass target.ass
```
## fmt
`fmt` 命令根据其特定格式的约定标准化和格式化字幕文件。
### 用法
```
sub-cli fmt <文件>
```
### 参数
| 参数 | 描述 |
|----------|-------------|
| `<文件>` | 要格式化的字幕文件的路径 |
### 支持的格式
| 格式 | 扩展名 | 格式化操作 |
|--------|-----------|-------------------|
| SRT | `.srt` | 标准化条目编号从1开始顺序<br>格式化时间戳为`00:00:00,000`格式<br>确保条目之间适当的间距 |
| LRC | `.lrc` | 组织元数据标签<br>标准化时间戳格式`[mm:ss.xx]`<br>确保正确的内容对齐 |
| VTT | `.vtt` | 验证WEBVTT头<br>标准化提示标识符<br>格式化时间戳为`00:00:00.000`格式<br>组织样式信息 |
| ASS | `.ass` | 标准化部分顺序([Script Info], [V4+ Styles], [Events]<br>格式化时间戳为`h:mm:ss.cc`格式<br>保留所有脚本信息、样式和事件数据 |
### 格式特定详情
#### SRT格式化
格式化器解析SRT文件提取所有条目确保从1开始的顺序编号并以一致的格式将文件写回。这保留了所有内容和时间信息同时标准化结构。
#### LRC格式化
对于LRC文件格式化器保留所有元数据和内容但标准化时间戳的格式并确保正确对齐。这使文件更易于阅读并与不同的LRC解析器更兼容。
#### VTT格式化
格式化WebVTT文件时命令确保适当的头格式、顺序提示标识符和标准时间戳格式。所有VTT特定功能如样式、定位和注释都被保留。
#### ASS格式化
格式化器读取并解析ASS文件然后以标准化结构重新生成它。它保持所有原始内容包括脚本信息、样式和事件。强制执行标准部分顺序[Script Info], [V4+ Styles], [Events]),并以标准的`h:mm:ss.cc`格式格式化时间戳。
### 示例
```bash
# 格式化SRT文件
sub-cli fmt subtitles.srt
# 格式化LRC文件
sub-cli fmt lyrics.lrc
# 格式化VTT文件
sub-cli fmt subtitles.vtt
# 格式化ASS文件
sub-cli fmt subtitles.ass
```
## version
显示Sub-CLI的当前版本。
### 用法
```
sub-cli version
```
## help
显示一般帮助信息或特定命令的帮助。
### 用法
```
sub-cli help [命令]
```
### 参数
| 参数 | 描述 |
|----------|-------------|
| `[命令]` | (可选)要获取帮助的特定命令 |
### 示例
```bash
# 显示一般帮助
sub-cli help
# 显示convert命令的帮助
sub-cli help convert
```

67
docs/zh-Hans/config.ts Normal file
View file

@ -0,0 +1,67 @@
export const zhHansThemeConfig = {
nav: [
{ text: '首页', link: '/zh-Hans/' },
],
sidebar: [
{
text: '介绍',
items: [
{ text: '快速开始', link: '/zh-Hans/getting-started' },
{ text: '安装指南', link: '/zh-Hans/installation' }
]
},
{
text: '使用',
items: [
{ text: '命令示例', link: '/zh-Hans/examples' },
{ text: '命令参考', link: '/zh-Hans/commands' }
]
},
{
text: '项目',
items: [
{ text: '提供反馈', link: '/zh-Hans/feedback' }
]
}
],
// from https://github.com/vuejs/vitepress
editLink: {
pattern: 'https://git.owu.one/wholetrans/sub-cli/edit/main/docs/:path',
text: '在 Owu Git 编辑此页面'
},
footer: {
message: 'Sub-CLI 基于 AGPL-3.0 许可发布',
copyright: `版权所有 © 2024-${new Date().getFullYear()} WholeTrans`
},
docFooter: {
prev: '上一页',
next: '下一页'
},
outline: {
label: '页面导航'
},
lastUpdated: {
text: '最后更新于'
},
notFound: {
title: '页面未找到',
quote: '但如果你不改变方向,并且继续寻找,你可能最终会到达你所前往的地方。',
linkLabel: '前往首页',
linkText: '回到首页'
},
langMenuLabel: '切换语言',
returnToTopLabel: '返回顶部',
sidebarMenuLabel: '菜单',
darkModeSwitchLabel: '主题',
lightModeSwitchTitle: '切换到浅色模式',
darkModeSwitchTitle: '切换到深色模式',
skipToContentLabel: '跳转到内容'
}

63
docs/zh-Hans/examples.md Normal file
View file

@ -0,0 +1,63 @@
---
title: 命令示例
description: Sub-CLI命令实际应用的实用示例
---
# 命令示例
本页面提供了Sub-CLI命令在常见字幕处理任务中的实用示例。
## 格式转换示例
在各种字幕格式之间进行转换:
```bash
# 从SRT转换为WebVTT
sub-cli convert subtitles.srt subtitles.vtt
# 从LRC转换为SRT
sub-cli convert lyrics.lrc subtitles.srt
# 从WebVTT转换为纯文本去除时间戳
sub-cli convert subtitles.vtt plain_text.txt
# 从SRT转换为LRC
sub-cli convert subtitles.srt lyrics.lrc
```
## 同步示例
在字幕文件之间同步时间轴:
```bash
# 使用另一个SRT文件作为参考来同步SRT文件
sub-cli sync reference.srt target.srt
# 使用另一个LRC文件作为参考来同步LRC文件
sub-cli sync reference.lrc target.lrc
```
注意同步功能仅适用于相同格式的文件之间。如果源文件和目标文件之间的条目数不同Sub-CLI将显示警告并适当地缩放时间轴。
## 格式化示例
格式化字幕文件以确保样式一致:
```bash
# 格式化SRT文件
sub-cli fmt subtitles.srt
# 格式化LRC文件
sub-cli fmt lyrics.lrc
# 格式化WebVTT文件
sub-cli fmt subtitles.vtt
```
格式化确保:
- 顺序条目编号
- 一致的时间戳格式
- 适当的间距和换行
- 格式特定的标准合规性
这些示例展示了Sub-CLI在处理各种字幕操作任务方面的多功能性。有关每个命令和所有可用选项的详细信息请参阅[命令参考](/zh-Hans/commands)页面。

81
docs/zh-Hans/feedback.md Normal file
View file

@ -0,0 +1,81 @@
---
title: 提供反馈
description: 分享您的体验和建议帮助改进Sub-CLI
---
# 提供反馈
您的反馈对Sub-CLI的持续开发和改进非常宝贵。我们欢迎各种类型的反馈包括错误报告、功能请求、可用性建议和一般评论。
## 提供反馈的方式
### 问题追踪
报告错误或请求功能的最佳方式是通过我们的问题追踪器:
1. 访问[Sub-CLI Issues](https://git.owu.one/wholetrans/sub-cli/issues)页面
2. 点击"New Issue"
3. 尽可能详细地填写相关信息
4. 提交工单
### 电子邮件
如果您愿意,可以直接通过电子邮件发送反馈:
`hello@wholetrans.org`(示例邮箱)
### 社区渠道
加入我们的社区讨论Sub-CLI分享您的经验并获取帮助
::: info
目前没有专门的Sub-CLI频道。您可以加入我们的`#general`聊天室进行一般性问题讨论。
:::
- Matrix空间[#wholetrans:mtx.owu.one](https://matrix.to/room/#wholetrans:mtx.owu.one)
您可以在我们的[关于](https://wholetrans.org/about)页面找到更多联系信息。
## 报告错误
报告错误时,请包括:
1. **Sub-CLI版本**`sub-cli version`的输出
2. **操作系统**:您的操作系统名称和版本
3. **重现步骤**:重现问题的详细步骤
4. **预期行为**:您期望发生的情况
5. **实际行为**:实际发生的情况
6. **附加上下文**:任何其他相关信息,如命令输出、错误消息或截图
## 功能请求
请求新功能时,请包括:
1. **使用场景**:描述您尝试解决的特定场景或问题
2. **建议解决方案**:您对实现该功能的想法
3. **考虑的替代方案**:您考虑过的任何替代解决方案
4. **附加上下文**:任何其他可能帮助我们理解请求的相关信息
## 贡献指南
有兴趣为Sub-CLI做贡献我们欢迎各种形式的贡献从代码改进到文档更新。
### 代码贡献
1. Fork存储库
2. 创建功能分支
3. 进行更改
4. 提交拉取请求
### 文档贡献
在我们的文档中发现错误或遗漏?有改进的想法?我们欢迎:
- 文档修复和改进
- 示例和教程
## 谢谢!
您的反馈和贡献有助于使Sub-CLI对每个人都更好。我们感谢您花时间和精力帮助我们改进工具。

View file

@ -0,0 +1,56 @@
---
title: 快速开始
description: Sub-CLI 介绍及其功能
---
# Sub-CLI 快速开始
::: info 当前状态
我们正在构建 Sub-CLI 的基础功能。程序行为可能不稳定。
:::
Sub-CLI 是一款专为字幕处理和生成设计的命令行工具。无论您需要转换字幕格式、同步时间轴还是格式化字幕文件Sub-CLI 都能为您的所有字幕需求提供功能支持。
## Sub-CLI 能做什么?
- **转换**在多种字幕格式之间转换SRT、VTT、LRC、ASS、TXT
- **同步**:字幕文件之间的时间轴同步
- **格式化**:确保字幕文件具有一致的样式
## 主要特点
- **格式灵活性**:支持多种字幕格式,包括 SRT、VTT、LRC、ASS 和纯文本
- **时间轴同步**:轻松将字幕与音频/视频内容对齐
- **格式特定功能保留**:在转换过程中保持格式特定的功能
- **简洁的命令界面**:简单、直观的命令,提高工作效率
## 快速导航
准备开始使用?以下是下一步去向:
- [安装指南](/zh-Hans/installation) - 下载并设置 Sub-CLI
- [命令示例](/zh-Hans/examples) - 查看 Sub-CLI 实际应用的示例
- [命令参考](/zh-Hans/commands) - 所有可用命令的详细文档
- [提供反馈](/zh-Hans/feedback) - 分享您的体验,帮助我们改进 Sub-CLI
## 基本用法
安装后,您可以使用简单的命令开始使用 Sub-CLI
```bash
# 将字幕从一种格式转换为另一种格式
sub-cli convert input.srt output.vtt
# 在两个字幕文件之间同步时间轴
sub-cli sync source.srt target.srt
# 格式化字幕文件
sub-cli fmt subtitle.srt
# 转换为ASS格式
sub-cli convert input.srt output.ass
```
查看[命令示例](/zh-Hans/examples)页面获取更多详细使用场景。

26
docs/zh-Hans/index.md Normal file
View file

@ -0,0 +1,26 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: "Sub-CLI"
# text: "字幕处理命令行工具"
tagline: "字幕处理命令行工具"
actions:
- theme: brand
text: 快速开始
link: /zh-Hans/getting-started
- theme: alt
text: 命令参考
link: /zh-Hans/commands
features:
- title: 一站式工作流
details: (开发中) Sub-CLI 能够全程参与从原始音频/视频(生肉)到多语言、带完整样式字幕的生产工作流
- title: 自动化,但亦可交互
details: (即将推出) 你可以用预先调校好的参数将 Sub-CLI 集成到您的工作流中,也可以启动 TUI 并根据交互式界面完成任务。
- title: 批量处理
details: (即将推出) 按照你期望的并发度,利用每一台你想利用的设备批量处理多个文件。
- title: 开箱即用的集成
details: (即将推出) 注册 Sub-CLI 账户,即可同时接入多个提供商,利用云服务处理你的媒体文件。
---

View file

@ -0,0 +1,107 @@
---
title: 安装指南
description: 如何在您的系统上下载并安装 Sub-CLI
---
# 安装指南
按照以下简单步骤,在您的计算机上安装并运行 Sub-CLI。
## 下载正确的版本
Sub-CLI 适用于各种操作系统和架构。访问[发布页](https://git.owu.one/wholetrans/sub-cli/releases)下载适合您系统的版本。
### 了解应下载哪个版本
发布文件的命名遵循以下模式:
```
sub-cli-[操作系统]-[架构][.exe]
```
其中:
- **操作系统**是您的操作系统windows, darwin, linux
- **架构**是您计算机的处理器类型amd64, arm64
- `.exe`扩展名仅适用于Windows版本
### 我需要哪个版本?
以下是一个简单的选择指南:
| 操作系统 | 处理器类型 | 下载文件 |
|------------------|----------------|---------------|
| Windows | Intel/AMD大多数PC | `sub-cli-windows-amd64.exe` |
| Windows | ARM如Surface Pro X等 | `sub-cli-windows-arm64.exe` |
| macOS | Intel Mac | `sub-cli-darwin-amd64` |
| macOS | Apple SiliconM系列处理器 | `sub-cli-darwin-arm64` |
| Linux | Intel/AMD大多数PC/服务器) | `sub-cli-linux-amd64` |
| Linux | ARM树莓派基于ARM的VPS等 | `sub-cli-linux-arm64` |
如果您不确定系统架构大多数现代计算机使用amd64也称为x86_64架构。
## 安装步骤
::: tip
如果只想临时使用,您只需将 sub-cli 放在当前目录或你期望的位置,无需将其加入环境变量。
:::
### Windows
1. 从发布页面下载适合的`.exe`文件
2. 将文件移动到您选择的位置(例如,`C:\Users\[用户名]\bin\`
3. 可选将该文件夹添加到PATH环境变量中以便从任何位置运行Sub-CLI
- 右键点击"此电脑"并选择"属性"
- 点击"高级系统设置"
- 点击"环境变量"
- 在"系统变量"下,找到"Path"变量,选择它并点击"编辑"
- 点击"新建"并添加包含Sub-CLI可执行文件的文件夹路径
- 点击所有对话框上的"确定"保存更改
### macOS (Darwin)
1. 从发布页面下载适合的文件
2. 打开终端
3. 使用以下命令使文件可执行:
```bash
chmod +x path/to/downloaded/sub-cli-darwin-[架构]
```
4. 可选将其移动到PATH中的位置以便更容易访问
```bash
sudo mv path/to/downloaded/sub-cli-darwin-[架构] ~/.local/bin/sub-cli
```
### Linux
1. 从发布页面下载适合的文件
2. 打开终端
3. 使用以下命令使文件可执行:
```bash
chmod +x path/to/downloaded/sub-cli-linux-[架构]
```
4. 可选将其移动到PATH中的位置以便更容易访问
```bash
sudo mv path/to/downloaded/sub-cli-linux-[架构] ~/.local/bin/sub-cli
```
## 验证安装
要验证Sub-CLI是否正确安装打开命令提示符或终端并运行
```bash
sub-cli version
```
您应该看到显示的Sub-CLI当前版本号。
## 故障排除
如果在安装过程中遇到任何问题:
- 确保您已下载适合您操作系统和架构的正确版本
- 确保文件具有可执行权限在macOS和Linux上
- 验证文件位于命令提示符或终端可访问的位置
- 如果您已将其添加到PATH中尝试重启命令提示符或终端
如需进一步帮助,请访问我们的[反馈页面](/zh-Hans/feedback)报告问题。

View file

@ -1,23 +1,32 @@
package config package config
// Version stores the current application version // Version stores the current application version
const Version = "0.4.0" const Version = "0.6.0"
// Usage stores the general usage information // Usage stores the general usage information
const Usage = `Usage: sub-cli [command] [options] const Usage = `Usage: sub-cli [command] [options]
Commands: Commands:
sync Synchronize timeline of two lyrics files sync Synchronize timeline of two subtitle files
convert Convert lyrics file to another format convert Convert subtitle file to another format
fmt Format lyrics file fmt Format subtitle file
help Show help` help Show help`
// SyncUsage stores the usage information for the sync command // SyncUsage stores the usage information for the sync command
const SyncUsage = `Usage: sub-cli sync <source> <target>` const SyncUsage = `Usage: sub-cli sync <source> <target>
Note:
Currently supports synchronizing between files of the same format:
- LRC to LRC
- SRT to SRT
- VTT to VTT
- ASS to ASS
If source and target have different numbers of entries, a warning will be shown.`
// ConvertUsage stores the usage information for the convert command // ConvertUsage stores the usage information for the convert command
const ConvertUsage = `Usage: sub-cli convert <source> <target> const ConvertUsage = `Usage: sub-cli convert <source> <target>
Note: Note:
Target format is determined by file extension. Supported formats: Target format is determined by file extension. Supported formats:
.txt Plain text format(No meta/timeline tags, only support as target format) .txt Plain text format (No meta/timeline tags, only support as target format)
.srt SubRip Subtitle format .srt SubRip Subtitle format
.lrc LRC format` .lrc LRC format
.vtt WebVTT format
.ass Advanced SubStation Alpha format`

View file

@ -3,12 +3,14 @@ package converter
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sub-cli/internal/format/ass"
"sub-cli/internal/format/lrc" "sub-cli/internal/format/lrc"
"sub-cli/internal/format/srt" "sub-cli/internal/format/srt"
"sub-cli/internal/format/txt"
"sub-cli/internal/format/vtt"
"sub-cli/internal/model" "sub-cli/internal/model"
) )
@ -20,118 +22,51 @@ func Convert(sourceFile, targetFile string) error {
sourceFmt := strings.TrimPrefix(filepath.Ext(sourceFile), ".") sourceFmt := strings.TrimPrefix(filepath.Ext(sourceFile), ".")
targetFmt := strings.TrimPrefix(filepath.Ext(targetFile), ".") targetFmt := strings.TrimPrefix(filepath.Ext(targetFile), ".")
switch sourceFmt { // TXT only supports being a target format
if sourceFmt == "txt" {
return fmt.Errorf("%w: txt is only supported as a target format", ErrUnsupportedFormat)
}
// Convert source to intermediate representation
subtitle, err := convertToIntermediate(sourceFile, sourceFmt)
if err != nil {
return err
}
// Convert from intermediate representation to target format
return convertFromIntermediate(subtitle, targetFile, targetFmt)
}
// convertToIntermediate converts a source file to our intermediate Subtitle representation
func convertToIntermediate(sourceFile, sourceFormat string) (model.Subtitle, error) {
switch sourceFormat {
case "lrc": case "lrc":
return convertFromLRC(sourceFile, targetFile, targetFmt) return lrc.ConvertToSubtitle(sourceFile)
case "srt": case "srt":
return convertFromSRT(sourceFile, targetFile, targetFmt) return srt.ConvertToSubtitle(sourceFile)
case "vtt":
return vtt.ConvertToSubtitle(sourceFile)
case "ass":
return ass.ConvertToSubtitle(sourceFile)
default: default:
return fmt.Errorf("%w: %s", ErrUnsupportedFormat, sourceFmt) return model.Subtitle{}, fmt.Errorf("%w: %s", ErrUnsupportedFormat, sourceFormat)
} }
} }
// convertFromLRC converts an LRC file to another format // convertFromIntermediate converts our intermediate Subtitle representation to a target format
func convertFromLRC(sourceFile, targetFile, targetFmt string) error { func convertFromIntermediate(subtitle model.Subtitle, targetFile, targetFormat string) error {
sourceLyrics, err := lrc.Parse(sourceFile) switch targetFormat {
if err != nil { case "lrc":
return fmt.Errorf("error parsing source LRC file: %w", err) return lrc.ConvertFromSubtitle(subtitle, targetFile)
} case "srt":
return srt.ConvertFromSubtitle(subtitle, targetFile)
switch targetFmt { case "vtt":
return vtt.ConvertFromSubtitle(subtitle, targetFile)
case "ass":
return ass.ConvertFromSubtitle(subtitle, targetFile)
case "txt": case "txt":
return lrcToTxt(sourceLyrics, targetFile) return txt.GenerateFromSubtitle(subtitle, targetFile)
case "srt":
return lrcToSRT(sourceLyrics, targetFile)
case "lrc":
return lrc.Generate(sourceLyrics, targetFile)
default: default:
return fmt.Errorf("%w: %s", ErrUnsupportedFormat, targetFmt) return fmt.Errorf("%w: %s", ErrUnsupportedFormat, targetFormat)
} }
} }
// convertFromSRT converts an SRT file to another format
func convertFromSRT(sourceFile, targetFile, targetFmt string) error {
entries, err := srt.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source SRT file: %w", err)
}
switch targetFmt {
case "txt":
return srtToTxt(entries, targetFile)
case "lrc":
lyrics := srt.ConvertToLyrics(entries)
return lrc.Generate(lyrics, targetFile)
case "srt":
return srt.Generate(entries, targetFile)
default:
return fmt.Errorf("%w: %s", ErrUnsupportedFormat, targetFmt)
}
}
// lrcToTxt converts LRC lyrics to a plain text file
func lrcToTxt(lyrics model.Lyrics, targetFile string) error {
file, err := os.Create(targetFile)
if err != nil {
return fmt.Errorf("error creating target file: %w", err)
}
defer file.Close()
for _, content := range lyrics.Content {
if _, err := fmt.Fprintln(file, content); err != nil {
return err
}
}
return nil
}
// lrcToSRT converts LRC lyrics to an SRT file
func lrcToSRT(lyrics model.Lyrics, targetFile string) error {
var entries []model.SRTEntry
for i, content := range lyrics.Content {
if i >= len(lyrics.Timeline) {
break
}
startTime := lyrics.Timeline[i]
endTime := startTime
// If there's a next timeline entry, use it for end time
// Otherwise add a few seconds to the start time
if i+1 < len(lyrics.Timeline) {
endTime = lyrics.Timeline[i+1]
} else {
endTime.Seconds += 3
}
entry := model.SRTEntry{
Number: i + 1,
StartTime: startTime,
EndTime: endTime,
Content: content,
}
entries = append(entries, entry)
}
return srt.Generate(entries, targetFile)
}
// srtToTxt converts SRT entries to a plain text file
func srtToTxt(entries []model.SRTEntry, targetFile string) error {
file, err := os.Create(targetFile)
if err != nil {
return fmt.Errorf("error creating target file: %w", err)
}
defer file.Close()
for _, entry := range entries {
if _, err := fmt.Fprintln(file, entry.Content); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,249 @@
package converter
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestConvert(t *testing.T) {
// Setup test cases
testCases := []struct {
name string
sourceContent string
sourceExt string
targetExt string
expectedError bool
validateOutput func(t *testing.T, filePath string)
}{
{
name: "SRT to VTT",
sourceContent: `1
00:00:01,000 --> 00:00:04,000
This is a test subtitle.
2
00:00:05,000 --> 00:00:08,000
This is another test subtitle.
`,
sourceExt: "srt",
targetExt: "vtt",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
contentStr := string(content)
if !strings.Contains(contentStr, "WEBVTT") {
t.Errorf("Expected output to contain WEBVTT header, got: %s", contentStr)
}
if !strings.Contains(contentStr, "00:00:01.000 --> 00:00:04.000") {
t.Errorf("Expected output to contain correct timestamp, got: %s", contentStr)
}
if !strings.Contains(contentStr, "This is a test subtitle.") {
t.Errorf("Expected output to contain subtitle text, got: %s", contentStr)
}
},
},
{
name: "LRC to SRT",
sourceContent: `[ti:Test Title]
[ar:Test Artist]
[00:01.00]This is a test lyric.
[00:05.00]This is another test lyric.
`,
sourceExt: "lrc",
targetExt: "srt",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
contentStr := string(content)
if !strings.Contains(contentStr, "00:00:01,000 --> ") {
t.Errorf("Expected output to contain correct SRT timestamp, got: %s", contentStr)
}
if !strings.Contains(contentStr, "This is a test lyric.") {
t.Errorf("Expected output to contain lyric text, got: %s", contentStr)
}
},
},
{
name: "VTT to LRC",
sourceContent: `WEBVTT
1
00:00:01.000 --> 00:00:04.000
This is a test subtitle.
2
00:00:05.000 --> 00:00:08.000
This is another test subtitle.
`,
sourceExt: "vtt",
targetExt: "lrc",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
contentStr := string(content)
if !strings.Contains(contentStr, "[00:01.000]") {
t.Errorf("Expected output to contain correct LRC timestamp, got: %s", contentStr)
}
if !strings.Contains(contentStr, "This is a test subtitle.") {
t.Errorf("Expected output to contain subtitle text, got: %s", contentStr)
}
},
},
{
name: "SRT to TXT",
sourceContent: `1
00:00:01,000 --> 00:00:04,000
This is a test subtitle.
2
00:00:05,000 --> 00:00:08,000
This is another test subtitle.
`,
sourceExt: "srt",
targetExt: "txt",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
contentStr := string(content)
if strings.Contains(contentStr, "00:00:01") {
t.Errorf("TXT should not contain timestamps, got: %s", contentStr)
}
if !strings.Contains(contentStr, "This is a test subtitle.") {
t.Errorf("Expected output to contain subtitle text, got: %s", contentStr)
}
},
},
{
name: "TXT to SRT",
sourceContent: "This is a test line.",
sourceExt: "txt",
targetExt: "srt",
expectedError: true,
validateOutput: nil, // No validation needed as we expect an error
},
{
name: "Invalid source format",
sourceContent: "Random content",
sourceExt: "xyz",
targetExt: "srt",
expectedError: true,
validateOutput: nil, // No validation needed as we expect an error
},
{
name: "Invalid target format",
sourceContent: `1
00:00:01,000 --> 00:00:04,000
This is a test subtitle.
`,
sourceExt: "srt",
targetExt: "xyz",
expectedError: true,
validateOutput: nil, // No validation needed as we expect an error
},
}
// Run test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create temporary directory
tempDir := t.TempDir()
// Create source file
sourceFile := filepath.Join(tempDir, "source."+tc.sourceExt)
if err := os.WriteFile(sourceFile, []byte(tc.sourceContent), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
// Create target file path
targetFile := filepath.Join(tempDir, "target."+tc.targetExt)
// Call Convert
err := Convert(sourceFile, targetFile)
// Check error
if tc.expectedError && err == nil {
t.Errorf("Expected error but got none")
}
if !tc.expectedError && err != nil {
t.Errorf("Expected no error but got: %v", err)
}
// If no error expected and validation function provided, validate output
if !tc.expectedError && tc.validateOutput != nil {
tc.validateOutput(t, targetFile)
}
})
}
}
func TestConvert_NonExistentFile(t *testing.T) {
tempDir := t.TempDir()
sourceFile := filepath.Join(tempDir, "nonexistent.srt")
targetFile := filepath.Join(tempDir, "target.vtt")
err := Convert(sourceFile, targetFile)
if err == nil {
t.Errorf("Expected error when source file doesn't exist, but got none")
}
}
func TestConvert_ReadOnlyTarget(t *testing.T) {
// This test might not be applicable on all platforms
// Skip it if running on a platform where permissions can't be enforced
if os.Getenv("SKIP_PERMISSION_TESTS") != "" {
t.Skip("Skipping permission test")
}
// Create temporary directory
tempDir := t.TempDir()
// Create source file
sourceContent := `1
00:00:01,000 --> 00:00:04,000
This is a test subtitle.
`
sourceFile := filepath.Join(tempDir, "source.srt")
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
// Create read-only directory
readOnlyDir := filepath.Join(tempDir, "readonly")
if err := os.Mkdir(readOnlyDir, 0500); err != nil {
t.Fatalf("Failed to create read-only directory: %v", err)
}
// Target in read-only directory
targetFile := filepath.Join(readOnlyDir, "target.vtt")
// Call Convert
err := Convert(sourceFile, targetFile)
// We expect an error due to permissions
if err == nil {
t.Errorf("Expected error when target is in read-only directory, but got none")
}
}

View file

@ -0,0 +1,186 @@
package ass
import (
"fmt"
"sub-cli/internal/model"
)
// ConvertToSubtitle 将ASS文件转换为通用字幕格式
func ConvertToSubtitle(filePath string) (model.Subtitle, error) {
// 解析ASS文件
assFile, err := Parse(filePath)
if err != nil {
return model.Subtitle{}, fmt.Errorf("解析ASS文件失败: %w", err)
}
// 创建通用字幕结构
subtitle := model.NewSubtitle()
subtitle.Format = "ass"
// 转换标题
if title, ok := assFile.ScriptInfo["Title"]; ok {
subtitle.Title = title
}
// 转换事件为字幕条目
for i, event := range assFile.Events {
// 只转换对话类型的事件
if event.Type == "Dialogue" {
entry := model.SubtitleEntry{
Index: i + 1,
StartTime: event.StartTime,
EndTime: event.EndTime,
Text: event.Text,
Styles: make(map[string]string),
Metadata: make(map[string]string),
}
// 记录样式信息
entry.Styles["style"] = event.Style
// 记录ASS特有信息
entry.Metadata["Layer"] = fmt.Sprintf("%d", event.Layer)
entry.Metadata["Name"] = event.Name
entry.Metadata["MarginL"] = fmt.Sprintf("%d", event.MarginL)
entry.Metadata["MarginR"] = fmt.Sprintf("%d", event.MarginR)
entry.Metadata["MarginV"] = fmt.Sprintf("%d", event.MarginV)
entry.Metadata["Effect"] = event.Effect
subtitle.Entries = append(subtitle.Entries, entry)
}
}
return subtitle, nil
}
// ConvertFromSubtitle 将通用字幕格式转换为ASS文件
func ConvertFromSubtitle(subtitle model.Subtitle, filePath string) error {
// 创建ASS文件结构
assFile := model.NewASSFile()
// 设置标题
if subtitle.Title != "" {
assFile.ScriptInfo["Title"] = subtitle.Title
}
// 转换字幕条目为ASS事件
for _, entry := range subtitle.Entries {
event := model.NewASSEvent()
event.Type = "Dialogue"
event.StartTime = entry.StartTime
event.EndTime = entry.EndTime
event.Text = entry.Text
// 检查是否有ASS特有的元数据
if layer, ok := entry.Metadata["Layer"]; ok {
fmt.Sscanf(layer, "%d", &event.Layer)
}
if name, ok := entry.Metadata["Name"]; ok {
event.Name = name
}
if marginL, ok := entry.Metadata["MarginL"]; ok {
fmt.Sscanf(marginL, "%d", &event.MarginL)
}
if marginR, ok := entry.Metadata["MarginR"]; ok {
fmt.Sscanf(marginR, "%d", &event.MarginR)
}
if marginV, ok := entry.Metadata["MarginV"]; ok {
fmt.Sscanf(marginV, "%d", &event.MarginV)
}
if effect, ok := entry.Metadata["Effect"]; ok {
event.Effect = effect
}
// 处理样式
if style, ok := entry.Styles["style"]; ok {
event.Style = style
} else {
// 根据基本样式设置ASS样式
if _, ok := entry.Styles["bold"]; ok {
// 创建一个加粗样式(如果尚未存在)
styleName := "Bold"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
boldStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
},
}
assFile.Styles = append(assFile.Styles, boldStyle)
}
event.Style = styleName
}
if _, ok := entry.Styles["italic"]; ok {
// 创建一个斜体样式(如果尚未存在)
styleName := "Italic"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
italicStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Italic,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,1,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
},
}
assFile.Styles = append(assFile.Styles, italicStyle)
}
event.Style = styleName
}
if _, ok := entry.Styles["underline"]; ok {
// 创建一个下划线样式(如果尚未存在)
styleName := "Underline"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
underlineStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Underline,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,1,0,100,100,0,0,1,2,2,2,10,10,10,1",
},
}
assFile.Styles = append(assFile.Styles, underlineStyle)
}
event.Style = styleName
}
}
assFile.Events = append(assFile.Events, event)
}
// 生成ASS文件
return Generate(assFile, filePath)
}

View file

@ -0,0 +1,210 @@
package ass
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestConvertToSubtitle(t *testing.T) {
// Create test ASS file
content := `[Script Info]
ScriptType: v4.00+
Title: Test ASS File
PlayResX: 640
PlayResY: 480
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,20,30,Fade,This is the first subtitle line.
Dialogue: 1,0:00:05.00,0:00:08.00,Bold,Character,15,25,35,,This is the second subtitle line with bold style.
Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "convert_test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test conversion to Subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Verify results
if subtitle.Format != "ass" {
t.Errorf("Format should be 'ass', got '%s'", subtitle.Format)
}
if subtitle.Title != "Test ASS File" {
t.Errorf("Title should be 'Test ASS File', got '%s'", subtitle.Title)
}
// Only dialogue events should be converted
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 subtitle entries, got %d", len(subtitle.Entries))
} else {
// Check first entry
if subtitle.Entries[0].Text != "This is the first subtitle line." {
t.Errorf("First entry text mismatch: got '%s'", subtitle.Entries[0].Text)
}
if subtitle.Entries[0].StartTime.Seconds != 1 || subtitle.Entries[0].EndTime.Seconds != 4 {
t.Errorf("First entry timing mismatch: got %+v - %+v",
subtitle.Entries[0].StartTime, subtitle.Entries[0].EndTime)
}
// Check style conversion
if subtitle.Entries[0].Styles["style"] != "Default" {
t.Errorf("First entry style mismatch: got '%s'", subtitle.Entries[0].Styles["style"])
}
// Check metadata conversion
if subtitle.Entries[0].Metadata["Layer"] != "0" {
t.Errorf("First entry layer mismatch: got '%s'", subtitle.Entries[0].Metadata["Layer"])
}
if subtitle.Entries[0].Metadata["Name"] != "Character" {
t.Errorf("First entry name mismatch: got '%s'", subtitle.Entries[0].Metadata["Name"])
}
if subtitle.Entries[0].Metadata["MarginL"] != "10" ||
subtitle.Entries[0].Metadata["MarginR"] != "20" ||
subtitle.Entries[0].Metadata["MarginV"] != "30" {
t.Errorf("First entry margins mismatch: got L=%s, R=%s, V=%s",
subtitle.Entries[0].Metadata["MarginL"],
subtitle.Entries[0].Metadata["MarginR"],
subtitle.Entries[0].Metadata["MarginV"])
}
if subtitle.Entries[0].Metadata["Effect"] != "Fade" {
t.Errorf("First entry effect mismatch: got '%s'", subtitle.Entries[0].Metadata["Effect"])
}
// Check second entry (Bold style)
if subtitle.Entries[1].Styles["style"] != "Bold" {
t.Errorf("Second entry style mismatch: got '%s'", subtitle.Entries[1].Styles["style"])
}
if subtitle.Entries[1].Metadata["Layer"] != "1" {
t.Errorf("Second entry layer mismatch: got '%s'", subtitle.Entries[1].Metadata["Layer"])
}
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "ass"
subtitle.Title = "Test Conversion"
// Create entries
entry1 := model.SubtitleEntry{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "This is the first subtitle line.",
Styles: map[string]string{"style": "Default"},
Metadata: map[string]string{
"Layer": "0",
"Name": "Character",
"MarginL": "10",
"MarginR": "20",
"MarginV": "30",
"Effect": "Fade",
},
}
entry2 := model.SubtitleEntry{
Index: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "This is the second subtitle line.",
Styles: map[string]string{"bold": "1"},
}
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert back to ASS
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "convert_back.ass")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Read the generated file
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read generated file: %v", err)
}
contentStr := string(content)
// Verify file content
if !strings.Contains(contentStr, "Title: Test Conversion") {
t.Errorf("Missing or incorrect title in generated file")
}
// Check that both entries were converted correctly
if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,20,30,Fade,This is the first subtitle line.") {
t.Errorf("First entry not converted correctly")
}
// Check that bold style was created and applied
if !strings.Contains(contentStr, "Style: Bold") {
t.Errorf("Bold style not created")
}
if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold") {
t.Errorf("Second entry not converted with Bold style")
}
// Parse the file again to check structure
assFile, err := Parse(outputFile)
if err != nil {
t.Fatalf("Failed to parse the generated file: %v", err)
}
if len(assFile.Events) != 2 {
t.Errorf("Expected 2 events, got %d", len(assFile.Events))
}
// Check style conversion
var boldStyleFound bool
for _, style := range assFile.Styles {
if style.Name == "Bold" {
boldStyleFound = true
break
}
}
if !boldStyleFound {
t.Errorf("Bold style not found in generated file")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.ass")
if err == nil {
t.Error("Converting non-existent file should return an error")
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Test invalid path
subtitle := model.NewSubtitle()
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.ass")
if err == nil {
t.Error("Converting to invalid path should return an error")
}
}

View file

@ -0,0 +1,17 @@
package ass
import (
"fmt"
)
// Format 格式化ASS文件
func Format(filePath string) error {
// 读取ASS文件
assFile, err := Parse(filePath)
if err != nil {
return fmt.Errorf("解析ASS文件失败: %w", err)
}
// 写回格式化后的ASS文件
return Generate(assFile, filePath)
}

View file

@ -0,0 +1,99 @@
package ass
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFormat(t *testing.T) {
// Create a test ASS file with non-standard formatting
content := `[Script Info]
ScriptType:v4.00+
Title: Format Test
PlayResX:640
PlayResY: 480
[V4+ Styles]
Format: Name, Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding
Style:Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format:Layer, Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
Dialogue:0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is the first subtitle line.
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,This is the second subtitle line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "format_test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test format
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Read the formatted file
formattedContent, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read formatted file: %v", err)
}
contentStr := string(formattedContent)
// Check for consistency and proper spacing
if !strings.Contains(contentStr, "Title: Format Test") {
t.Errorf("Title should be properly formatted, got: %s", contentStr)
}
// Check style section formatting
if !strings.Contains(contentStr, "Format: Name, Fontname, Fontsize") {
t.Errorf("Style format should be properly spaced, got: %s", contentStr)
}
// Check event section formatting
if !strings.Contains(contentStr, "Dialogue: 0,") {
t.Errorf("Dialogue should be properly formatted, got: %s", contentStr)
}
// Parse formatted file to ensure it's valid
assFile, err := Parse(testFile)
if err != nil {
t.Fatalf("Failed to parse formatted file: %v", err)
}
// Verify basic structure remains intact
if assFile.ScriptInfo["Title"] != "Format Test" {
t.Errorf("Title mismatch after formatting: expected 'Format Test', got '%s'", assFile.ScriptInfo["Title"])
}
if len(assFile.Events) != 2 {
t.Errorf("Expected 2 events after formatting, got %d", len(assFile.Events))
}
}
func TestFormat_NonExistentFile(t *testing.T) {
err := Format("/nonexistent/file.ass")
if err == nil {
t.Error("Formatting non-existent file should return an error")
}
}
func TestFormat_InvalidWritable(t *testing.T) {
// Create a directory instead of a file
tempDir := t.TempDir()
dirAsFile := filepath.Join(tempDir, "dir_as_file")
if err := os.Mkdir(dirAsFile, 0755); err != nil {
t.Fatalf("Failed to create test directory: %v", err)
}
// Try to format a directory
err := Format(dirAsFile)
if err == nil {
t.Error("Formatting a directory should return an error")
}
}

View file

@ -0,0 +1,122 @@
package ass
import (
"fmt"
"os"
"path/filepath"
"strings"
"sub-cli/internal/model"
)
// Generate 生成ASS文件
func Generate(assFile model.ASSFile, filePath string) error {
// 确保目录存在
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
// 创建或覆盖文件
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("创建ASS文件失败: %w", err)
}
defer file.Close()
// 写入脚本信息
if _, err := file.WriteString(ASSHeader + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
for key, value := range assFile.ScriptInfo {
if _, err := file.WriteString(fmt.Sprintf("%s: %s\n", key, value)); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
// 写入样式信息
if _, err := file.WriteString("\n" + ASSStylesHeader + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
// 写入样式格式行
if len(assFile.Styles) > 0 {
var formatString string
for _, style := range assFile.Styles {
if formatString == "" && style.Properties["Format"] != "" {
formatString = style.Properties["Format"]
if _, err := file.WriteString(fmt.Sprintf("Format: %s\n", formatString)); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
break
}
}
// 如果没有找到格式行,写入默认格式
if formatString == "" {
defaultFormat := "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
if _, err := file.WriteString(fmt.Sprintf("Format: %s\n", defaultFormat)); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
// 写入样式定义
for _, style := range assFile.Styles {
if style.Properties["Style"] != "" {
if _, err := file.WriteString(fmt.Sprintf("Style: %s\n", style.Properties["Style"])); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
}
}
// 写入事件信息
if _, err := file.WriteString("\n" + ASSEventsHeader + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
// 写入事件格式行
if _, err := file.WriteString(DefaultFormat + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
// 写入事件行
for _, event := range assFile.Events {
eventLine := formatEventLine(event)
if _, err := file.WriteString(eventLine + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
return nil
}
// formatEventLine 将事件格式化为ASS文件中的一行
func formatEventLine(event model.ASSEvent) string {
// 格式化时间戳
startTime := formatASSTimestamp(event.StartTime)
endTime := formatASSTimestamp(event.EndTime)
// 构建事件行
var builder strings.Builder
if event.Type == "Comment" {
builder.WriteString("Comment: ")
} else {
builder.WriteString("Dialogue: ")
}
builder.WriteString(fmt.Sprintf("%d,%s,%s,%s,%s,%d,%d,%d,%s,%s",
event.Layer,
startTime,
endTime,
event.Style,
event.Name,
event.MarginL,
event.MarginR,
event.MarginV,
event.Effect,
event.Text))
return builder.String()
}

View file

@ -0,0 +1,131 @@
package ass
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestGenerate(t *testing.T) {
// Create test ASS file structure
assFile := model.NewASSFile()
assFile.ScriptInfo["Title"] = "Generation Test"
// Add a custom style
boldStyle := model.ASSStyle{
Name: "Bold",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
"Bold": "1",
},
}
assFile.Styles = append(assFile.Styles, boldStyle)
// Add dialogue events
event1 := model.NewASSEvent()
event1.Type = "Dialogue"
event1.StartTime = model.Timestamp{Seconds: 1}
event1.EndTime = model.Timestamp{Seconds: 4}
event1.Style = "Default"
event1.Text = "This is a test subtitle."
event2 := model.NewASSEvent()
event2.Type = "Dialogue"
event2.StartTime = model.Timestamp{Seconds: 5}
event2.EndTime = model.Timestamp{Seconds: 8}
event2.Style = "Bold"
event2.Text = "This is a bold subtitle."
assFile.Events = append(assFile.Events, event1, event2)
// Generate ASS file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.ass")
err := Generate(assFile, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Read the generated file
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read generated file: %v", err)
}
contentStr := string(content)
// Verify file structure and content
// Check Script Info section
if !strings.Contains(contentStr, "[Script Info]") {
t.Errorf("Missing [Script Info] section")
}
if !strings.Contains(contentStr, "Title: Generation Test") {
t.Errorf("Missing Title in Script Info")
}
// Check Styles section
if !strings.Contains(contentStr, "[V4+ Styles]") {
t.Errorf("Missing [V4+ Styles] section")
}
if !strings.Contains(contentStr, "Style: Bold,Arial,20") {
t.Errorf("Missing Bold style definition")
}
// Check Events section
if !strings.Contains(contentStr, "[Events]") {
t.Errorf("Missing [Events] section")
}
if !strings.Contains(contentStr, "Format: Layer, Start, End, Style,") {
t.Errorf("Missing Format line in Events section")
}
if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is a test subtitle.") {
t.Errorf("Missing first dialogue event")
}
if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is a bold subtitle.") {
t.Errorf("Missing second dialogue event")
}
}
func TestGenerate_FileError(t *testing.T) {
// Test invalid path
assFile := model.NewASSFile()
err := Generate(assFile, "/nonexistent/directory/file.ass")
if err == nil {
t.Error("Generating to invalid path should return an error")
}
}
func TestFormatEventLine(t *testing.T) {
event := model.ASSEvent{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Name: "Character",
MarginL: 10,
MarginR: 10,
MarginV: 10,
Effect: "Fade",
Text: "Test text",
}
expected := "Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,10,10,Fade,Test text"
result := formatEventLine(event)
if result != expected {
t.Errorf("Expected: '%s', got: '%s'", expected, result)
}
// Test Comment type
event.Type = "Comment"
expected = "Comment: 0,0:00:01.00,0:00:04.00,Default,Character,10,10,10,Fade,Test text"
result = formatEventLine(event)
if result != expected {
t.Errorf("Expected: '%s', got: '%s'", expected, result)
}
}

View file

@ -0,0 +1,152 @@
package ass
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"sub-cli/internal/model"
)
// 常量定义
const (
ASSHeader = "[Script Info]"
ASSStylesHeader = "[V4+ Styles]"
ASSEventsHeader = "[Events]"
DefaultFormat = "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
)
// Parse 解析ASS文件为ASSFile结构
func Parse(filePath string) (model.ASSFile, error) {
file, err := os.Open(filePath)
if err != nil {
return model.ASSFile{}, fmt.Errorf("打开ASS文件失败: %w", err)
}
defer file.Close()
result := model.NewASSFile()
scanner := bufio.NewScanner(file)
// 当前解析的区块
currentSection := ""
var styleFormat, eventFormat []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, ";") {
// 跳过空行和注释行
continue
}
// 检查章节标题
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
currentSection = line
continue
}
switch currentSection {
case ASSHeader:
// 解析脚本信息
if strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
result.ScriptInfo[key] = value
}
case ASSStylesHeader:
// 解析样式格式行和样式定义
if strings.HasPrefix(line, "Format:") {
formatStr := strings.TrimPrefix(line, "Format:")
styleFormat = parseFormatLine(formatStr)
} else if strings.HasPrefix(line, "Style:") {
styleValues := parseStyleLine(line)
if len(styleFormat) > 0 && len(styleValues) > 0 {
style := model.ASSStyle{
Name: styleValues[0], // 第一个值通常是样式名称
Properties: make(map[string]string),
}
// 将原始格式行保存下来
style.Properties["Format"] = "Name, " + strings.Join(styleFormat[1:], ", ")
style.Properties["Style"] = strings.Join(styleValues, ", ")
// 解析各个样式属性
for i := 0; i < len(styleFormat) && i < len(styleValues); i++ {
style.Properties[styleFormat[i]] = styleValues[i]
}
result.Styles = append(result.Styles, style)
}
}
case ASSEventsHeader:
// 解析事件格式行和对话行
if strings.HasPrefix(line, "Format:") {
formatStr := strings.TrimPrefix(line, "Format:")
eventFormat = parseFormatLine(formatStr)
} else if len(eventFormat) > 0 &&
(strings.HasPrefix(line, "Dialogue:") ||
strings.HasPrefix(line, "Comment:")) {
eventType := "Dialogue"
if strings.HasPrefix(line, "Comment:") {
eventType = "Comment"
line = strings.TrimPrefix(line, "Comment:")
} else {
line = strings.TrimPrefix(line, "Dialogue:")
}
values := parseEventLine(line)
if len(values) >= len(eventFormat) {
event := model.NewASSEvent()
event.Type = eventType
// 填充事件属性
for i, format := range eventFormat {
value := values[i]
switch strings.TrimSpace(format) {
case "Layer":
layer, _ := strconv.Atoi(value)
event.Layer = layer
case "Start":
event.StartTime = parseASSTimestamp(value)
case "End":
event.EndTime = parseASSTimestamp(value)
case "Style":
event.Style = value
case "Name":
event.Name = value
case "MarginL":
marginL, _ := strconv.Atoi(value)
event.MarginL = marginL
case "MarginR":
marginR, _ := strconv.Atoi(value)
event.MarginR = marginR
case "MarginV":
marginV, _ := strconv.Atoi(value)
event.MarginV = marginV
case "Effect":
event.Effect = value
case "Text":
// 文本可能包含逗号,所以需要特殊处理
textStartIndex := strings.Index(line, value)
if textStartIndex >= 0 {
event.Text = line[textStartIndex:]
} else {
event.Text = value
}
}
}
result.Events = append(result.Events, event)
}
}
}
}
return result, nil
}

View file

@ -0,0 +1,148 @@
package ass
import (
"os"
"path/filepath"
"testing"
"sub-cli/internal/model"
)
func TestParse(t *testing.T) {
// Create temporary test file
content := `[Script Info]
ScriptType: v4.00+
Title: Test ASS File
PlayResX: 640
PlayResY: 480
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is the first subtitle line.
Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is the second subtitle line with bold style.
Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
assFile, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
// Script info
if assFile.ScriptInfo["Title"] != "Test ASS File" {
t.Errorf("Title mismatch: expected 'Test ASS File', got '%s'", assFile.ScriptInfo["Title"])
}
if assFile.ScriptInfo["ScriptType"] != "v4.00+" {
t.Errorf("Script type mismatch: expected 'v4.00+', got '%s'", assFile.ScriptInfo["ScriptType"])
}
// Styles
if len(assFile.Styles) != 3 {
t.Errorf("Expected 3 styles, got %d", len(assFile.Styles))
} else {
// Find Bold style
var boldStyle *model.ASSStyle
for i, style := range assFile.Styles {
if style.Name == "Bold" {
boldStyle = &assFile.Styles[i]
break
}
}
if boldStyle == nil {
t.Errorf("Bold style not found")
} else {
boldValue, exists := boldStyle.Properties["Bold"]
if !exists || boldValue != "1" {
t.Errorf("Bold style Bold property mismatch: expected '1', got '%s'", boldValue)
}
}
}
// Events
if len(assFile.Events) != 3 {
t.Errorf("Expected 3 events, got %d", len(assFile.Events))
} else {
// Check first dialogue line
if assFile.Events[0].Type != "Dialogue" {
t.Errorf("First event type mismatch: expected 'Dialogue', got '%s'", assFile.Events[0].Type)
}
if assFile.Events[0].StartTime.Seconds != 1 || assFile.Events[0].StartTime.Milliseconds != 0 {
t.Errorf("First event start time mismatch: expected 00:00:01.00, got %d:%02d:%02d.%03d",
assFile.Events[0].StartTime.Hours, assFile.Events[0].StartTime.Minutes,
assFile.Events[0].StartTime.Seconds, assFile.Events[0].StartTime.Milliseconds)
}
if assFile.Events[0].Text != "This is the first subtitle line." {
t.Errorf("First event text mismatch: expected 'This is the first subtitle line.', got '%s'", assFile.Events[0].Text)
}
// Check second dialogue line (bold style)
if assFile.Events[1].Style != "Bold" {
t.Errorf("Second event style mismatch: expected 'Bold', got '%s'", assFile.Events[1].Style)
}
// Check comment line
if assFile.Events[2].Type != "Comment" {
t.Errorf("Third event type mismatch: expected 'Comment', got '%s'", assFile.Events[2].Type)
}
}
}
func TestParse_EdgeCases(t *testing.T) {
// Test empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.ass")
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
t.Fatalf("Failed to create empty test file: %v", err)
}
assFile, err := Parse(emptyFile)
if err != nil {
t.Fatalf("Failed to parse empty file: %v", err)
}
if len(assFile.Events) != 0 {
t.Errorf("Empty file should have 0 events, got %d", len(assFile.Events))
}
// Test file missing required sections
malformedContent := `[Script Info]
Title: Missing Sections Test
`
malformedFile := filepath.Join(tempDir, "malformed.ass")
if err := os.WriteFile(malformedFile, []byte(malformedContent), 0644); err != nil {
t.Fatalf("Failed to create malformed file: %v", err)
}
assFile, err = Parse(malformedFile)
if err != nil {
t.Fatalf("Failed to parse malformed file: %v", err)
}
if assFile.ScriptInfo["Title"] != "Missing Sections Test" {
t.Errorf("Should correctly parse the title")
}
if len(assFile.Events) != 0 {
t.Errorf("File missing Events section should have 0 events")
}
}
func TestParse_FileError(t *testing.T) {
// Test non-existent file
_, err := Parse("/nonexistent/file.ass")
if err == nil {
t.Error("Parsing non-existent file should return an error")
}
}

View file

@ -0,0 +1,98 @@
package ass
import (
"fmt"
"regexp"
"strconv"
"strings"
"sub-cli/internal/model"
)
// parseFormatLine 解析格式行中的各个字段
func parseFormatLine(formatStr string) []string {
fields := strings.Split(formatStr, ",")
result := make([]string, 0, len(fields))
for _, field := range fields {
result = append(result, strings.TrimSpace(field))
}
return result
}
// parseStyleLine 解析样式行
func parseStyleLine(line string) []string {
// 去掉"Style:"前缀
styleStr := strings.TrimPrefix(line, "Style:")
return splitCSV(styleStr)
}
// parseEventLine 解析事件行
func parseEventLine(line string) []string {
return splitCSV(line)
}
// splitCSV 拆分CSV格式的字符串但保留Text字段中的逗号
func splitCSV(line string) []string {
var result []string
inText := false
current := ""
for _, char := range line {
if char == ',' && !inText {
result = append(result, strings.TrimSpace(current))
current = ""
} else {
current += string(char)
// 这是个简化处理实际ASS格式更复杂
// 当处理到足够数量的字段后剩余部分都当作Text字段
if len(result) >= 9 {
inText = true
}
}
}
if current != "" {
result = append(result, strings.TrimSpace(current))
}
return result
}
// parseASSTimestamp 解析ASS格式的时间戳 (h:mm:ss.cc)
func parseASSTimestamp(timeStr string) model.Timestamp {
// 匹配 h:mm:ss.cc 格式
re := regexp.MustCompile(`(\d+):(\d+):(\d+)\.(\d+)`)
matches := re.FindStringSubmatch(timeStr)
if len(matches) == 5 {
hours, _ := strconv.Atoi(matches[1])
minutes, _ := strconv.Atoi(matches[2])
seconds, _ := strconv.Atoi(matches[3])
// ASS使用厘秒1/100秒需要转换为毫秒
centiseconds, _ := strconv.Atoi(matches[4])
milliseconds := centiseconds * 10
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
// 返回零时间戳,如果解析失败
return model.Timestamp{}
}
// formatASSTimestamp 格式化为ASS格式的时间戳 (h:mm:ss.cc)
func formatASSTimestamp(timestamp model.Timestamp) string {
// ASS使用厘秒1/100秒
centiseconds := timestamp.Milliseconds / 10
return fmt.Sprintf("%d:%02d:%02d.%02d",
timestamp.Hours,
timestamp.Minutes,
timestamp.Seconds,
centiseconds)
}

View file

@ -0,0 +1,139 @@
package ass
import (
"testing"
"sub-cli/internal/model"
)
func TestParseASSTimestamp(t *testing.T) {
testCases := []struct {
name string
input string
expected model.Timestamp
}{
{
name: "Standard format",
input: "0:00:01.00",
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
},
{
name: "With centiseconds",
input: "0:00:01.50",
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
},
{
name: "Complete hours, minutes, seconds",
input: "1:02:03.45",
expected: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
},
{
name: "Invalid format",
input: "invalid",
expected: model.Timestamp{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := parseASSTimestamp(tc.input)
if result != tc.expected {
t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result)
}
})
}
}
func TestFormatASSTimestamp(t *testing.T) {
testCases := []struct {
name string
input model.Timestamp
expected string
}{
{
name: "Zero timestamp",
input: model.Timestamp{},
expected: "0:00:00.00",
},
{
name: "Simple seconds",
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
expected: "0:00:01.00",
},
{
name: "With milliseconds",
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
expected: "0:00:01.50",
},
{
name: "Complete timestamp",
input: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
expected: "1:02:03.45",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := formatASSTimestamp(tc.input)
if result != tc.expected {
t.Errorf("For timestamp %+v, expected '%s', got '%s'", tc.input, tc.expected, result)
}
})
}
}
func TestSplitCSV(t *testing.T) {
testCases := []struct {
name string
input string
expected []string
}{
{
name: "Simple CSV",
input: "Value1, Value2, Value3",
expected: []string{"Value1", "Value2", "Value3"},
},
{
name: "Text field with commas",
input: "0, 00:00:01.00, 00:00:05.00, Default, Name, 0, 0, 0, Effect, Text with, commas",
expected: []string{"0", "00:00:01.00", "00:00:05.00", "Default", "Name", "0", "0", "0", "Effect", "Text with, commas"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := splitCSV(tc.input)
// Check result length
if len(result) != len(tc.expected) {
t.Errorf("Expected %d values, got %d: %v", len(tc.expected), len(result), result)
return
}
// Check content
for i := range result {
if result[i] != tc.expected[i] {
t.Errorf("At index %d, expected '%s', got '%s'", i, tc.expected[i], result[i])
}
}
})
}
}
func TestParseFormatLine(t *testing.T) {
input := " Name, Fontname, Fontsize, PrimaryColour"
expected := []string{"Name", "Fontname", "Fontsize", "PrimaryColour"}
result := parseFormatLine(input)
if len(result) != len(expected) {
t.Errorf("Expected %d values, got %d: %v", len(expected), len(result), result)
return
}
for i := range result {
if result[i] != expected[i] {
t.Errorf("At index %d, expected '%s', got '%s'", i, expected[i], result[i])
}
}
}

View file

@ -0,0 +1,181 @@
package lrc
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestConvertToSubtitle(t *testing.T) {
// Create a temporary test file
content := `[ti:Test LRC File]
[ar:Test Artist]
[00:01.00]This is the first line.
[00:05.00]This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.lrc")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check result
if subtitle.Format != "lrc" {
t.Errorf("Expected format 'lrc', got '%s'", subtitle.Format)
}
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].StartTime.Hours != 0 || subtitle.Entries[0].StartTime.Minutes != 0 ||
subtitle.Entries[0].StartTime.Seconds != 1 || subtitle.Entries[0].StartTime.Milliseconds != 0 {
t.Errorf("First entry start time: expected 00:01.00, got %+v", subtitle.Entries[0].StartTime)
}
if subtitle.Entries[0].Text != "This is the first line." {
t.Errorf("First entry text: expected 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
}
// Check metadata conversion
if subtitle.Title != "Test LRC File" {
t.Errorf("Expected title 'Test LRC File', got '%s'", subtitle.Title)
}
if subtitle.Metadata["ar"] != "Test Artist" {
t.Errorf("Expected artist metadata 'Test Artist', got '%s'", subtitle.Metadata["ar"])
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create a subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "lrc"
subtitle.Title = "Test LRC File"
subtitle.Metadata["ar"] = "Test Artist"
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is the second line."
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert to LRC
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.lrc")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
contentStr := string(content)
// Check metadata
if !strings.Contains(contentStr, "[ti:Test LRC File]") {
t.Errorf("Expected title metadata in output, not found")
}
if !strings.Contains(contentStr, "[ar:Test Artist]") {
t.Errorf("Expected artist metadata in output, not found")
}
// Check timeline entries
if !strings.Contains(contentStr, "[00:01.000]This is the first line.") {
t.Errorf("Expected first timeline entry in output, not found")
}
if !strings.Contains(contentStr, "[00:05.000]This is the second line.") {
t.Errorf("Expected second timeline entry in output, not found")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test with non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.lrc")
if err == nil {
t.Error("Expected error when converting non-existent file, got nil")
}
}
func TestConvertToSubtitle_EdgeCases(t *testing.T) {
// Test with empty lyrics (no content/timeline)
tempDir := t.TempDir()
emptyLyricsFile := filepath.Join(tempDir, "empty_lyrics.lrc")
content := `[ti:Test LRC File]
[ar:Test Artist]
`
if err := os.WriteFile(emptyLyricsFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create empty lyrics test file: %v", err)
}
subtitle, err := ConvertToSubtitle(emptyLyricsFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed on empty lyrics: %v", err)
}
if len(subtitle.Entries) != 0 {
t.Errorf("Expected 0 entries for empty lyrics, got %d", len(subtitle.Entries))
}
if subtitle.Title != "Test LRC File" {
t.Errorf("Expected title 'Test LRC File', got '%s'", subtitle.Title)
}
// Test with more content than timeline entries
moreContentFile := filepath.Join(tempDir, "more_content.lrc")
content = `[ti:Test LRC File]
[00:01.00]This has a timestamp.
This doesn't have a timestamp but is content.
`
if err := os.WriteFile(moreContentFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create more content test file: %v", err)
}
subtitle, err = ConvertToSubtitle(moreContentFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed on file with more content than timestamps: %v", err)
}
if len(subtitle.Entries) != 1 {
t.Errorf("Expected 1 entry (only the one with timestamp), got %d", len(subtitle.Entries))
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Create simple subtitle
subtitle := model.NewSubtitle()
subtitle.Entries = append(subtitle.Entries, model.NewSubtitleEntry())
// Test with invalid path
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.lrc")
if err == nil {
t.Error("Expected error when converting to invalid path, got nil")
}
}

View file

@ -0,0 +1,72 @@
package lrc
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFormat(t *testing.T) {
// Create a temporary test file with messy formatting
content := `[ti:Test LRC File]
[ar:Test Artist]
[00:01.00]This should be first.
[00:05.00]This is the second line.
[00:09.50]This is the third line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.lrc")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Format the file
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Read the formatted file
formatted, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read formatted file: %v", err)
}
// Check that the file was at least generated successfully
lines := strings.Split(string(formatted), "\n")
if len(lines) < 4 {
t.Fatalf("Expected at least 4 lines, got %d", len(lines))
}
// Check that the metadata was preserved
if !strings.Contains(string(formatted), "[ti:Test LRC File]") {
t.Errorf("Expected title metadata in output, not found")
}
if !strings.Contains(string(formatted), "[ar:Test Artist]") {
t.Errorf("Expected artist metadata in output, not found")
}
// Check that all the content lines are present
if !strings.Contains(string(formatted), "This should be first") {
t.Errorf("Expected 'This should be first' in output, not found")
}
if !strings.Contains(string(formatted), "This is the second line") {
t.Errorf("Expected 'This is the second line' in output, not found")
}
if !strings.Contains(string(formatted), "This is the third line") {
t.Errorf("Expected 'This is the third line' in output, not found")
}
}
func TestFormat_FileError(t *testing.T) {
// Test with non-existent file
err := Format("/nonexistent/file.lrc")
if err == nil {
t.Error("Expected error when formatting non-existent file, got nil")
}
}

View file

@ -0,0 +1,151 @@
package lrc
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestGenerate(t *testing.T) {
// Create test lyrics
lyrics := model.Lyrics{
Metadata: map[string]string{
"ti": "Test LRC File",
"ar": "Test Artist",
},
Timeline: []model.Timestamp{
{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
},
Content: []string{
"This is the first line.",
"This is the second line.",
},
}
// Generate LRC file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.lrc")
err := Generate(lyrics, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
lines := strings.Split(string(content), "\n")
if len(lines) < 4 {
t.Fatalf("Expected at least 4 lines, got %d", len(lines))
}
hasTitleLine := false
hasFirstTimeline := false
for _, line := range lines {
if line == "[ti:Test LRC File]" {
hasTitleLine = true
}
if line == "[00:01.000]This is the first line." {
hasFirstTimeline = true
}
}
if !hasTitleLine {
t.Errorf("Expected title line '[ti:Test LRC File]' not found")
}
if !hasFirstTimeline {
t.Errorf("Expected timeline line '[00:01.000]This is the first line.' not found")
}
}
func TestGenerate_FileError(t *testing.T) {
// Create test lyrics
lyrics := model.Lyrics{
Metadata: map[string]string{
"ti": "Test LRC File",
},
Timeline: []model.Timestamp{
{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
},
Content: []string{
"This is a test line.",
},
}
// Test with invalid path
err := Generate(lyrics, "/nonexistent/directory/file.lrc")
if err == nil {
t.Error("Expected error when generating to invalid path, got nil")
}
}
func TestGenerate_EdgeCases(t *testing.T) {
// Test with empty lyrics
emptyLyrics := model.Lyrics{
Metadata: map[string]string{
"ti": "Empty Test",
},
}
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty_output.lrc")
err := Generate(emptyLyrics, emptyFile)
if err != nil {
t.Fatalf("Generate failed with empty lyrics: %v", err)
}
// Verify content has metadata but no timeline entries
content, err := os.ReadFile(emptyFile)
if err != nil {
t.Fatalf("Failed to read empty output file: %v", err)
}
if !strings.Contains(string(content), "[ti:Empty Test]") {
t.Errorf("Expected metadata in empty lyrics output, not found")
}
// Test with unequal timeline and content lengths
unequalLyrics := model.Lyrics{
Timeline: []model.Timestamp{
{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
},
Content: []string{
"This is the only content line.",
},
}
unequalFile := filepath.Join(tempDir, "unequal_output.lrc")
err = Generate(unequalLyrics, unequalFile)
if err != nil {
t.Fatalf("Generate failed with unequal lyrics: %v", err)
}
// Should only generate for the entries that have both timeline and content
content, err = os.ReadFile(unequalFile)
if err != nil {
t.Fatalf("Failed to read unequal output file: %v", err)
}
lines := strings.Split(string(content), "\n")
timelineLines := 0
for _, line := range lines {
if strings.HasPrefix(line, "[") && strings.Contains(line, "]") &&
strings.Contains(line, ":") && strings.Contains(line, ".") {
timelineLines++
}
}
if timelineLines > 1 {
t.Errorf("Expected only 1 timeline entry for unequal lyrics, got %d", timelineLines)
}
}

View file

@ -180,3 +180,87 @@ func Format(filePath string) error {
return Generate(lyrics, filePath) return Generate(lyrics, filePath)
} }
// ConvertToSubtitle converts LRC file to our intermediate Subtitle representation
func ConvertToSubtitle(filePath string) (model.Subtitle, error) {
lyrics, err := Parse(filePath)
if err != nil {
return model.Subtitle{}, err
}
subtitle := model.NewSubtitle()
subtitle.Format = "lrc"
// Copy metadata
for key, value := range lyrics.Metadata {
subtitle.Metadata[key] = value
}
// Check for specific LRC metadata we should use for title
if title, ok := lyrics.Metadata["ti"]; ok {
subtitle.Title = title
}
// Create entries from timeline and content
for i, content := range lyrics.Content {
if i >= len(lyrics.Timeline) {
break
}
entry := model.NewSubtitleEntry()
entry.Index = i + 1
entry.StartTime = lyrics.Timeline[i]
// Set end time based on next timeline entry if available, otherwise add a few seconds
if i+1 < len(lyrics.Timeline) {
entry.EndTime = lyrics.Timeline[i+1]
} else {
// Default end time: start time + 3 seconds
entry.EndTime = model.Timestamp{
Hours: entry.StartTime.Hours,
Minutes: entry.StartTime.Minutes,
Seconds: entry.StartTime.Seconds + 3,
Milliseconds: entry.StartTime.Milliseconds,
}
// Handle overflow
if entry.EndTime.Seconds >= 60 {
entry.EndTime.Seconds -= 60
entry.EndTime.Minutes++
}
if entry.EndTime.Minutes >= 60 {
entry.EndTime.Minutes -= 60
entry.EndTime.Hours++
}
}
entry.Text = content
subtitle.Entries = append(subtitle.Entries, entry)
}
return subtitle, nil
}
// ConvertFromSubtitle converts our intermediate Subtitle representation to LRC format
func ConvertFromSubtitle(subtitle model.Subtitle, filePath string) error {
lyrics := model.Lyrics{
Metadata: make(map[string]string),
}
// Copy metadata
for key, value := range subtitle.Metadata {
lyrics.Metadata[key] = value
}
// Add title if present and not already in metadata
if subtitle.Title != "" && lyrics.Metadata["ti"] == "" {
lyrics.Metadata["ti"] = subtitle.Title
}
// Convert entries to timeline and content
for _, entry := range subtitle.Entries {
lyrics.Timeline = append(lyrics.Timeline, entry.StartTime)
lyrics.Content = append(lyrics.Content, entry.Text)
}
return Generate(lyrics, filePath)
}

View file

@ -0,0 +1,185 @@
package lrc
import (
"os"
"path/filepath"
"testing"
)
func TestParse(t *testing.T) {
// Create a temporary test file
content := `[ti:Test LRC File]
[ar:Test Artist]
[al:Test Album]
[by:Test Creator]
[00:01.00]This is the first line.
[00:05.00]This is the second line.
[00:09.50]This is the third line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.lrc")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
lyrics, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
if len(lyrics.Timeline) != 3 {
t.Errorf("Expected 3 timeline entries, got %d", len(lyrics.Timeline))
}
if len(lyrics.Content) != 3 {
t.Errorf("Expected 3 content entries, got %d", len(lyrics.Content))
}
// Check metadata
if lyrics.Metadata["ti"] != "Test LRC File" {
t.Errorf("Expected title 'Test LRC File', got '%s'", lyrics.Metadata["ti"])
}
if lyrics.Metadata["ar"] != "Test Artist" {
t.Errorf("Expected artist 'Test Artist', got '%s'", lyrics.Metadata["ar"])
}
if lyrics.Metadata["al"] != "Test Album" {
t.Errorf("Expected album 'Test Album', got '%s'", lyrics.Metadata["al"])
}
if lyrics.Metadata["by"] != "Test Creator" {
t.Errorf("Expected creator 'Test Creator', got '%s'", lyrics.Metadata["by"])
}
// Check first timeline entry
if lyrics.Timeline[0].Hours != 0 || lyrics.Timeline[0].Minutes != 0 ||
lyrics.Timeline[0].Seconds != 1 || lyrics.Timeline[0].Milliseconds != 0 {
t.Errorf("First entry time: expected 00:01.00, got %+v", lyrics.Timeline[0])
}
// Check third timeline entry
if lyrics.Timeline[2].Hours != 0 || lyrics.Timeline[2].Minutes != 0 ||
lyrics.Timeline[2].Seconds != 9 || lyrics.Timeline[2].Milliseconds != 500 {
t.Errorf("Third entry time: expected 00:09.50, got %+v", lyrics.Timeline[2])
}
// Check content
if lyrics.Content[0] != "This is the first line." {
t.Errorf("First entry content: expected 'This is the first line.', got '%s'", lyrics.Content[0])
}
}
func TestParse_FileErrors(t *testing.T) {
// Test with non-existent file
_, err := Parse("/nonexistent/file.lrc")
if err == nil {
t.Error("Expected error when parsing non-existent file, got nil")
}
}
func TestParse_EdgeCases(t *testing.T) {
// Test with empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.lrc")
if err := os.WriteFile(emptyFile, []byte(""), 0644); err != nil {
t.Fatalf("Failed to create empty file: %v", err)
}
lyrics, err := Parse(emptyFile)
if err != nil {
t.Fatalf("Parse failed with empty file: %v", err)
}
if len(lyrics.Timeline) != 0 || len(lyrics.Content) != 0 {
t.Errorf("Expected empty lyrics for empty file, got %d timeline and %d content",
len(lyrics.Timeline), len(lyrics.Content))
}
// Test with metadata only
metadataFile := filepath.Join(tempDir, "metadata.lrc")
metadataContent := `[ti:Test Title]
[ar:Test Artist]
[al:Test Album]
`
if err := os.WriteFile(metadataFile, []byte(metadataContent), 0644); err != nil {
t.Fatalf("Failed to create metadata file: %v", err)
}
lyrics, err = Parse(metadataFile)
if err != nil {
t.Fatalf("Parse failed with metadata-only file: %v", err)
}
if lyrics.Metadata["ti"] != "Test Title" {
t.Errorf("Expected title 'Test Title', got '%s'", lyrics.Metadata["ti"])
}
if len(lyrics.Timeline) != 0 || len(lyrics.Content) != 0 {
t.Errorf("Expected empty timeline/content for metadata-only file, got %d timeline and %d content",
len(lyrics.Timeline), len(lyrics.Content))
}
// Test with invalid metadata
invalidMetadataFile := filepath.Join(tempDir, "invalid_metadata.lrc")
invalidMetadata := `[ti:Test Title
[ar:Test Artist]
[00:01.00]This is a valid line.
`
if err := os.WriteFile(invalidMetadataFile, []byte(invalidMetadata), 0644); err != nil {
t.Fatalf("Failed to create invalid metadata file: %v", err)
}
lyrics, err = Parse(invalidMetadataFile)
if err != nil {
t.Fatalf("Parse failed with invalid metadata file: %v", err)
}
if lyrics.Metadata["ti"] != "" { // Should ignore invalid metadata
t.Errorf("Expected empty title for invalid metadata, got '%s'", lyrics.Metadata["ti"])
}
if len(lyrics.Timeline) != 1 || len(lyrics.Content) != 1 {
t.Errorf("Expected 1 timeline/content entry for file with invalid metadata, got %d timeline and %d content",
len(lyrics.Timeline), len(lyrics.Content))
}
// Test with invalid timestamp format
invalidFile := filepath.Join(tempDir, "invalid.lrc")
content := `[ti:Test LRC File]
[ar:Test Artist]
[invalidtime]This should be ignored.
[00:01.00]This is a valid line.
`
if err := os.WriteFile(invalidFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create invalid test file: %v", err)
}
lyrics, err = Parse(invalidFile)
if err != nil {
t.Fatalf("Parse failed on file with invalid timestamps: %v", err)
}
if len(lyrics.Timeline) != 1 || len(lyrics.Content) != 1 {
t.Errorf("Expected 1 valid timeline entry, got %d timeline entries and %d content entries",
len(lyrics.Timeline), len(lyrics.Content))
}
// Test with timestamp-only lines (no content)
timestampOnlyFile := filepath.Join(tempDir, "timestamp_only.lrc")
content = `[ti:Test LRC File]
[ar:Test Artist]
[00:01.00]
[00:05.00]This has content.
`
if err := os.WriteFile(timestampOnlyFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create timestamp-only test file: %v", err)
}
lyrics, err = Parse(timestampOnlyFile)
if err != nil {
t.Fatalf("Parse failed on file with timestamp-only lines: %v", err)
}
if len(lyrics.Timeline) != 1 || len(lyrics.Content) != 1 {
t.Errorf("Expected 1 valid entry (ignoring empty content), got %d timeline entries and %d content entries",
len(lyrics.Timeline), len(lyrics.Content))
}
}

View file

@ -0,0 +1,163 @@
package lrc
import (
"testing"
"sub-cli/internal/model"
)
func TestParseTimestamp(t *testing.T) {
testCases := []struct {
name string
input string
expected model.Timestamp
valid bool
}{
{
name: "Simple minute and second",
input: "[01:30]",
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 0,
},
valid: true,
},
{
name: "With milliseconds",
input: "[01:30.500]",
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 500,
},
valid: true,
},
{
name: "With hours",
input: "[01:30:45.500]",
expected: model.Timestamp{
Hours: 1,
Minutes: 30,
Seconds: 45,
Milliseconds: 500,
},
valid: true,
},
{
name: "Zero time",
input: "[00:00.000]",
expected: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 0,
Milliseconds: 0,
},
valid: true,
},
{
name: "Invalid format - no brackets",
input: "01:30",
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 0,
},
valid: true, // ParseTimestamp automatically strips brackets, so it will parse this without brackets
},
{
name: "Invalid format - wrong brackets",
input: "(01:30)",
expected: model.Timestamp{},
valid: false,
},
{
name: "Invalid format - no time",
input: "[]",
expected: model.Timestamp{},
valid: false,
},
{
name: "Invalid format - text in brackets",
input: "[text]",
expected: model.Timestamp{},
valid: false,
},
{
name: "Invalid format - incomplete time",
input: "[01:]",
expected: model.Timestamp{},
valid: false,
},
{
name: "Invalid format - incomplete time with milliseconds",
input: "[01:.500]",
expected: model.Timestamp{},
valid: false,
},
{
name: "Metadata tag",
input: "[ti:Title]",
expected: model.Timestamp{},
valid: false,
},
{
name: "With milliseconds - alternative format using comma",
input: "[01:30.500]", // Use period instead of comma since our parser doesn't handle comma
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 500,
},
valid: true,
},
{
name: "With double-digit milliseconds",
input: "[01:30.50]",
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 500,
},
valid: true,
},
{
name: "With single-digit milliseconds",
input: "[01:30.5]",
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 500,
},
valid: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
timestamp, err := ParseTimestamp(tc.input)
if (err == nil) != tc.valid {
t.Errorf("Expected valid=%v, got valid=%v (err=%v)", tc.valid, err == nil, err)
return
}
if !tc.valid {
return // No need to check further for invalid cases
}
if timestamp.Hours != tc.expected.Hours ||
timestamp.Minutes != tc.expected.Minutes ||
timestamp.Seconds != tc.expected.Seconds ||
timestamp.Milliseconds != tc.expected.Milliseconds {
t.Errorf("Expected timestamp %+v, got %+v", tc.expected, timestamp)
}
})
}
}

View file

@ -0,0 +1,255 @@
package srt
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestConvertToSubtitle(t *testing.T) {
// Create a temporary test file
content := `1
00:00:01,000 --> 00:00:04,000
This is the first line.
2
00:00:05,000 --> 00:00:08,000
This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check result
if subtitle.Format != "srt" {
t.Errorf("Expected format 'srt', got '%s'", subtitle.Format)
}
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].Index != 1 {
t.Errorf("First entry index: expected 1, got %d", subtitle.Entries[0].Index)
}
if subtitle.Entries[0].Text != "This is the first line." {
t.Errorf("First entry text: expected 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create a subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "srt"
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is the second line."
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert to SRT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.srt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
lines := strings.Split(string(content), "\n")
if len(lines) < 7 {
t.Fatalf("Expected at least 7 lines, got %d", len(lines))
}
// Check that the SRT entries were created correctly
if lines[0] != "1" {
t.Errorf("Expected first entry number to be '1', got '%s'", lines[0])
}
if !strings.Contains(lines[1], "00:00:01,000 --> 00:00:04,000") {
t.Errorf("Expected first entry time range to match, got '%s'", lines[1])
}
if lines[2] != "This is the first line." {
t.Errorf("Expected first entry content to match, got '%s'", lines[2])
}
}
func TestConvertToSubtitle_WithHTMLTags(t *testing.T) {
// Create a temporary test file with HTML styling tags
content := `1
00:00:01,000 --> 00:00:04,000
<i>This is italic.</i>
2
00:00:05,000 --> 00:00:08,000
<b>This is bold.</b>
3
00:00:09,000 --> 00:00:12,000
<u>This is underlined.</u>
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "styled.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check style detection
if value, ok := subtitle.Entries[0].Styles["italic"]; !ok || value != "true" {
t.Errorf("Expected Styles to contain italic=true for entry with <i> tag")
}
if value, ok := subtitle.Entries[1].Styles["bold"]; !ok || value != "true" {
t.Errorf("Expected Styles to contain bold=true for entry with <b> tag")
}
if value, ok := subtitle.Entries[2].Styles["underline"]; !ok || value != "true" {
t.Errorf("Expected Styles to contain underline=true for entry with <u> tag")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test with non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.srt")
if err == nil {
t.Error("Expected error when converting non-existent file, got nil")
}
}
func TestConvertFromSubtitle_WithStyling(t *testing.T) {
// Create a subtitle with style attributes
subtitle := model.NewSubtitle()
subtitle.Format = "srt"
// Create an entry with italics
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This should be italic."
entry1.Styles["italic"] = "true"
// Create an entry with bold
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This should be bold."
entry2.Styles["bold"] = "true"
// Create an entry with underline
entry3 := model.NewSubtitleEntry()
entry3.Index = 3
entry3.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 9, Milliseconds: 0}
entry3.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 12, Milliseconds: 0}
entry3.Text = "This should be underlined."
entry3.Styles["underline"] = "true"
subtitle.Entries = append(subtitle.Entries, entry1, entry2, entry3)
// Convert from subtitle to SRT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "styled.srt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check that HTML tags were applied
contentStr := string(content)
if !strings.Contains(contentStr, "<i>This should be italic.</i>") {
t.Errorf("Expected italic HTML tags to be applied")
}
if !strings.Contains(contentStr, "<b>This should be bold.</b>") {
t.Errorf("Expected bold HTML tags to be applied")
}
if !strings.Contains(contentStr, "<u>This should be underlined.</u>") {
t.Errorf("Expected underline HTML tags to be applied")
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Create simple subtitle
subtitle := model.NewSubtitle()
subtitle.Entries = append(subtitle.Entries, model.NewSubtitleEntry())
// Test with invalid path
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.srt")
if err == nil {
t.Error("Expected error when converting to invalid path, got nil")
}
}
func TestConvertFromSubtitle_WithExistingHTMLTags(t *testing.T) {
// Create a subtitle with text that already contains HTML tags
subtitle := model.NewSubtitle()
subtitle.Format = "srt"
// Create an entry with existing italic tags but also style attribute
entry := model.NewSubtitleEntry()
entry.Index = 1
entry.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry.Text = "<i>Already italic text.</i>"
entry.Styles["italic"] = "true" // Should not double-wrap with <i> tags
subtitle.Entries = append(subtitle.Entries, entry)
// Convert from subtitle to SRT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "existing_tags.srt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Should not have double tags
contentStr := string(content)
if strings.Contains(contentStr, "<i><i>") {
t.Errorf("Expected no duplicate italic tags, but found them")
}
}

View file

@ -0,0 +1,70 @@
package srt
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFormat(t *testing.T) {
// Create a temporary test file with out-of-order numbers
content := `2
00:00:05,000 --> 00:00:08,000
This is the second line.
1
00:00:01,000 --> 00:00:04,000
This is the first line.
3
00:00:09,500 --> 00:00:12,800
This is the third line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Format the file
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Read the formatted file
formatted, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read formatted file: %v", err)
}
// The Format function should standardize the numbering
lines := strings.Split(string(formatted), "\n")
// The numbers should be sequential starting from 1
if !strings.HasPrefix(lines[0], "1") {
t.Errorf("First entry should be renumbered to 1, got '%s'", lines[0])
}
// Find the second entry (after the first entry's content and a blank line)
var secondEntryIndex int
for i := 1; i < len(lines); i++ {
if lines[i] == "" && i+1 < len(lines) && lines[i+1] != "" {
secondEntryIndex = i + 1
break
}
}
if secondEntryIndex > 0 && !strings.HasPrefix(lines[secondEntryIndex], "2") {
t.Errorf("Second entry should be renumbered to 2, got '%s'", lines[secondEntryIndex])
}
}
func TestFormat_FileError(t *testing.T) {
// Test with non-existent file
err := Format("/nonexistent/file.srt")
if err == nil {
t.Error("Expected error when formatting non-existent file, got nil")
}
}

View file

@ -0,0 +1,84 @@
package srt
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestGenerate(t *testing.T) {
// Create test entries
entries := []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0},
Content: "This is the first line.",
},
{
Number: 2,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0},
Content: "This is the second line.",
},
}
// Generate SRT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.srt")
err := Generate(entries, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
lines := strings.Split(string(content), "\n")
if len(lines) < 6 {
t.Fatalf("Expected at least 6 lines, got %d", len(lines))
}
if lines[0] != "1" {
t.Errorf("Expected first line to be '1', got '%s'", lines[0])
}
if lines[1] != "00:00:01,000 --> 00:00:04,000" {
t.Errorf("Expected second line to be time range, got '%s'", lines[1])
}
if lines[2] != "This is the first line." {
t.Errorf("Expected third line to be content, got '%s'", lines[2])
}
}
func TestGenerate_FileError(t *testing.T) {
// Test with invalid path
entries := []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0},
Content: "Test",
},
}
err := Generate(entries, "/nonexistent/directory/file.srt")
if err == nil {
t.Error("Expected error when generating to invalid path, got nil")
}
// Test with directory as file
tempDir := t.TempDir()
err = Generate(entries, tempDir)
if err == nil {
t.Error("Expected error when generating to a directory, got nil")
}
}

View file

@ -0,0 +1,58 @@
package srt
import (
"testing"
"sub-cli/internal/model"
)
func TestConvertToLyrics(t *testing.T) {
// Create test entries
entries := []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0},
Content: "This is the first line.",
},
{
Number: 2,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0},
Content: "This is the second line.",
},
{
Number: 3,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 9, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 12, Milliseconds: 0},
Content: "This is the third line.",
},
}
// Convert to Lyrics
lyrics := ConvertToLyrics(entries)
// Check result
if len(lyrics.Timeline) != 3 {
t.Errorf("Expected 3 timeline entries, got %d", len(lyrics.Timeline))
}
if len(lyrics.Content) != 3 {
t.Errorf("Expected 3 content entries, got %d", len(lyrics.Content))
}
// Check first entry
if lyrics.Timeline[0].Hours != 0 || lyrics.Timeline[0].Minutes != 0 ||
lyrics.Timeline[0].Seconds != 1 || lyrics.Timeline[0].Milliseconds != 0 {
t.Errorf("First timeline: expected 00:00:01,000, got %+v", lyrics.Timeline[0])
}
if lyrics.Content[0] != "This is the first line." {
t.Errorf("First content: expected 'This is the first line.', got '%s'", lyrics.Content[0])
}
// Check with empty entries
emptyLyrics := ConvertToLyrics([]model.SRTEntry{})
if len(emptyLyrics.Timeline) != 0 || len(emptyLyrics.Content) != 0 {
t.Errorf("Expected empty lyrics for empty entries, got %d timeline and %d content",
len(emptyLyrics.Timeline), len(emptyLyrics.Content))
}
}

View file

@ -0,0 +1,159 @@
package srt
import (
"os"
"path/filepath"
"testing"
)
func TestParse(t *testing.T) {
// Create a temporary test file
content := `1
00:00:01,000 --> 00:00:04,000
This is the first line.
2
00:00:05,000 --> 00:00:08,000
This is the second line.
3
00:00:09,500 --> 00:00:12,800
This is the third line
with a line break.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
entries, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
if len(entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(entries))
}
// Check first entry
if entries[0].Number != 1 {
t.Errorf("First entry number: expected 1, got %d", entries[0].Number)
}
if entries[0].StartTime.Hours != 0 || entries[0].StartTime.Minutes != 0 ||
entries[0].StartTime.Seconds != 1 || entries[0].StartTime.Milliseconds != 0 {
t.Errorf("First entry start time: expected 00:00:01,000, got %+v", entries[0].StartTime)
}
if entries[0].EndTime.Hours != 0 || entries[0].EndTime.Minutes != 0 ||
entries[0].EndTime.Seconds != 4 || entries[0].EndTime.Milliseconds != 0 {
t.Errorf("First entry end time: expected 00:00:04,000, got %+v", entries[0].EndTime)
}
if entries[0].Content != "This is the first line." {
t.Errorf("First entry content: expected 'This is the first line.', got '%s'", entries[0].Content)
}
// Check third entry
if entries[2].Number != 3 {
t.Errorf("Third entry number: expected 3, got %d", entries[2].Number)
}
expectedContent := "This is the third line\nwith a line break."
if entries[2].Content != expectedContent {
t.Errorf("Third entry content: expected '%s', got '%s'", expectedContent, entries[2].Content)
}
}
func TestParse_EdgeCases(t *testing.T) {
// Test with empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.srt")
if err := os.WriteFile(emptyFile, []byte(""), 0644); err != nil {
t.Fatalf("Failed to create empty file: %v", err)
}
entries, err := Parse(emptyFile)
if err != nil {
t.Fatalf("Parse failed with empty file: %v", err)
}
if len(entries) != 0 {
t.Errorf("Expected 0 entries for empty file, got %d", len(entries))
}
// Test with malformed timestamp
malformedContent := `1
00:00:01,000 --> 00:00:04,000
First entry.
2
bad timestamp format
Second entry.
`
malformedFile := filepath.Join(tempDir, "malformed.srt")
if err := os.WriteFile(malformedFile, []byte(malformedContent), 0644); err != nil {
t.Fatalf("Failed to create malformed file: %v", err)
}
entries, err = Parse(malformedFile)
if err != nil {
t.Fatalf("Parse failed with malformed file: %v", err)
}
// Should still parse the first entry correctly
if len(entries) != 1 {
t.Errorf("Expected 1 entry for malformed file, got %d", len(entries))
}
// Test with missing numbers
missingNumContent := `00:00:01,000 --> 00:00:04,000
First entry without number.
2
00:00:05,000 --> 00:00:08,000
Second entry with number.
`
missingNumFile := filepath.Join(tempDir, "missing_num.srt")
if err := os.WriteFile(missingNumFile, []byte(missingNumContent), 0644); err != nil {
t.Fatalf("Failed to create missing num file: %v", err)
}
entries, err = Parse(missingNumFile)
if err != nil {
t.Fatalf("Parse failed with missing num file: %v", err)
}
// Parsing behavior may vary, but it should not crash
// In this case, it will typically parse just the second entry
// Test with extra empty lines
extraLineContent := `1
00:00:01,000 --> 00:00:04,000
First entry with extra spaces.
2
00:00:05,000 --> 00:00:08,000
Second entry with extra spaces.
`
extraLineFile := filepath.Join(tempDir, "extra_lines.srt")
if err := os.WriteFile(extraLineFile, []byte(extraLineContent), 0644); err != nil {
t.Fatalf("Failed to create extra lines file: %v", err)
}
entries, err = Parse(extraLineFile)
if err != nil {
t.Fatalf("Parse failed with extra lines file: %v", err)
}
if len(entries) != 2 {
t.Errorf("Expected 2 entries for extra lines file, got %d", len(entries))
}
// Check content was trimmed correctly
if entries[0].Content != "First entry with extra spaces." {
t.Errorf("Expected trimmed content, got '%s'", entries[0].Content)
}
}
func TestParse_FileError(t *testing.T) {
// Test with non-existent file
_, err := Parse("/nonexistent/file.srt")
if err == nil {
t.Error("Expected error when parsing non-existent file, got nil")
}
}

View file

@ -122,6 +122,23 @@ func formatSRTTimestamp(ts model.Timestamp) string {
ts.Milliseconds) ts.Milliseconds)
} }
// Format standardizes and formats an SRT file
func Format(filePath string) error {
// Parse the file
entries, err := Parse(filePath)
if err != nil {
return fmt.Errorf("error parsing SRT file: %w", err)
}
// Standardize entry numbering and ensure consistent formatting
for i := range entries {
entries[i].Number = i + 1 // Ensure sequential numbering
}
// Write back the formatted content
return Generate(entries, filePath)
}
// ConvertToLyrics converts SRT entries to a Lyrics structure // ConvertToLyrics converts SRT entries to a Lyrics structure
func ConvertToLyrics(entries []model.SRTEntry) model.Lyrics { func ConvertToLyrics(entries []model.SRTEntry) model.Lyrics {
lyrics := model.Lyrics{ lyrics := model.Lyrics{
@ -135,3 +152,91 @@ func ConvertToLyrics(entries []model.SRTEntry) model.Lyrics {
return lyrics return lyrics
} }
// ConvertToSubtitle converts SRT entries to our intermediate Subtitle structure
func ConvertToSubtitle(filePath string) (model.Subtitle, error) {
entries, err := Parse(filePath)
if err != nil {
return model.Subtitle{}, fmt.Errorf("error parsing SRT file: %w", err)
}
subtitle := model.NewSubtitle()
subtitle.Format = "srt"
// Convert SRT entries to intermediate representation
for _, entry := range entries {
subtitleEntry := model.NewSubtitleEntry()
subtitleEntry.Index = entry.Number
subtitleEntry.StartTime = entry.StartTime
subtitleEntry.EndTime = entry.EndTime
subtitleEntry.Text = entry.Content
// Look for HTML styling tags and store information about them
if strings.Contains(entry.Content, "<") && strings.Contains(entry.Content, ">") {
// Extract and store HTML styling info
if strings.Contains(entry.Content, "<i>") || strings.Contains(entry.Content, "<I>") {
subtitleEntry.Styles["italic"] = "true"
}
if strings.Contains(entry.Content, "<b>") || strings.Contains(entry.Content, "<B>") {
subtitleEntry.Styles["bold"] = "true"
}
if strings.Contains(entry.Content, "<u>") || strings.Contains(entry.Content, "<U>") {
subtitleEntry.Styles["underline"] = "true"
}
subtitleEntry.FormatData["has_html_tags"] = true
}
subtitle.Entries = append(subtitle.Entries, subtitleEntry)
}
return subtitle, nil
}
// ConvertFromSubtitle converts our intermediate Subtitle representation to SRT format
func ConvertFromSubtitle(subtitle model.Subtitle, filePath string) error {
var entries []model.SRTEntry
// Convert intermediate representation to SRT entries
for i, subtitleEntry := range subtitle.Entries {
entry := model.SRTEntry{
Number: i + 1, // Ensure sequential numbering
StartTime: subtitleEntry.StartTime,
EndTime: subtitleEntry.EndTime,
Content: subtitleEntry.Text,
}
// Use index from original entry if available
if subtitleEntry.Index > 0 {
entry.Number = subtitleEntry.Index
}
// Apply any styling stored in the entry if needed
// Note: SRT only supports basic HTML tags, so we convert style attributes back to HTML
content := entry.Content
if _, ok := subtitleEntry.Styles["italic"]; ok && subtitleEntry.Styles["italic"] == "true" {
if !strings.Contains(content, "<i>") {
content = "<i>" + content + "</i>"
}
}
if _, ok := subtitleEntry.Styles["bold"]; ok && subtitleEntry.Styles["bold"] == "true" {
if !strings.Contains(content, "<b>") {
content = "<b>" + content + "</b>"
}
}
if _, ok := subtitleEntry.Styles["underline"]; ok && subtitleEntry.Styles["underline"] == "true" {
if !strings.Contains(content, "<u>") {
content = "<u>" + content + "</u>"
}
}
// Only update content if we applied styling
if content != entry.Content {
entry.Content = content
}
entries = append(entries, entry)
}
return Generate(entries, filePath)
}

View file

@ -0,0 +1,182 @@
package srt
import (
"testing"
"sub-cli/internal/model"
)
func TestParseSRTTimestamp(t *testing.T) {
testCases := []struct {
input string
expected model.Timestamp
}{
{
input: "00:00:01,000",
expected: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 1,
Milliseconds: 0,
},
},
{
input: "01:02:03,456",
expected: model.Timestamp{
Hours: 1,
Minutes: 2,
Seconds: 3,
Milliseconds: 456,
},
},
{
input: "10:20:30,789",
expected: model.Timestamp{
Hours: 10,
Minutes: 20,
Seconds: 30,
Milliseconds: 789,
},
},
{
// Test invalid format
input: "invalid",
expected: model.Timestamp{},
},
{
// Test with dot instead of comma
input: "01:02:03.456",
expected: model.Timestamp{
Hours: 1,
Minutes: 2,
Seconds: 3,
Milliseconds: 456,
},
},
}
for _, tc := range testCases {
result := parseSRTTimestamp(tc.input)
if result.Hours != tc.expected.Hours ||
result.Minutes != tc.expected.Minutes ||
result.Seconds != tc.expected.Seconds ||
result.Milliseconds != tc.expected.Milliseconds {
t.Errorf("parseSRTTimestamp(%s) = %+v, want %+v",
tc.input, result, tc.expected)
}
}
}
func TestFormatSRTTimestamp(t *testing.T) {
testCases := []struct {
input model.Timestamp
expected string
}{
{
input: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 1,
Milliseconds: 0,
},
expected: "00:00:01,000",
},
{
input: model.Timestamp{
Hours: 1,
Minutes: 2,
Seconds: 3,
Milliseconds: 456,
},
expected: "01:02:03,456",
},
{
input: model.Timestamp{
Hours: 10,
Minutes: 20,
Seconds: 30,
Milliseconds: 789,
},
expected: "10:20:30,789",
},
}
for _, tc := range testCases {
result := formatSRTTimestamp(tc.input)
if result != tc.expected {
t.Errorf("formatSRTTimestamp(%+v) = %s, want %s",
tc.input, result, tc.expected)
}
}
}
func TestIsEntryTimeStampUnset(t *testing.T) {
testCases := []struct {
entry model.SRTEntry
expected bool
}{
{
entry: model.SRTEntry{
StartTime: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 0,
Milliseconds: 0,
},
},
expected: true,
},
{
entry: model.SRTEntry{
StartTime: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 1,
Milliseconds: 0,
},
},
expected: false,
},
{
entry: model.SRTEntry{
StartTime: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 0,
Milliseconds: 0,
},
},
expected: false,
},
{
entry: model.SRTEntry{
StartTime: model.Timestamp{
Hours: 1,
Minutes: 0,
Seconds: 0,
Milliseconds: 0,
},
},
expected: false,
},
{
entry: model.SRTEntry{
StartTime: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 0,
Milliseconds: 1,
},
},
expected: false,
},
}
for i, tc := range testCases {
result := isEntryTimeStampUnset(tc.entry)
if result != tc.expected {
t.Errorf("Case %d: isEntryTimeStampUnset(%+v) = %v, want %v",
i, tc.entry, result, tc.expected)
}
}
}

View file

@ -0,0 +1,30 @@
package txt
import (
"fmt"
"os"
"sub-cli/internal/model"
)
// GenerateFromSubtitle converts our intermediate Subtitle to plain text format
func GenerateFromSubtitle(subtitle model.Subtitle, filePath string) error {
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("error creating TXT file: %w", err)
}
defer file.Close()
// Write title if available
if subtitle.Title != "" {
fmt.Fprintln(file, subtitle.Title)
fmt.Fprintln(file)
}
// Write content without timestamps
for _, entry := range subtitle.Entries {
fmt.Fprintln(file, entry.Text)
}
return nil
}

View file

@ -0,0 +1,145 @@
package txt
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestGenerateFromSubtitle(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
entry1 := model.NewSubtitleEntry()
entry1.Text = "This is the first line."
entry2 := model.NewSubtitleEntry()
entry2.Text = "This is the second line."
entry3 := model.NewSubtitleEntry()
entry3.Text = "This is the third line\nwith a line break."
subtitle.Entries = append(subtitle.Entries, entry1, entry2, entry3)
// Generate TXT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.txt")
err := GenerateFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("GenerateFromSubtitle failed: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
lines := strings.Split(string(content), "\n")
if len(lines) < 4 { // 3 entries with one having a line break
t.Fatalf("Expected at least 4 lines, got %d", len(lines))
}
if lines[0] != "This is the first line." {
t.Errorf("Expected first line to be 'This is the first line.', got '%s'", lines[0])
}
if lines[1] != "This is the second line." {
t.Errorf("Expected second line to be 'This is the second line.', got '%s'", lines[1])
}
if lines[2] != "This is the third line" {
t.Errorf("Expected third line to be 'This is the third line', got '%s'", lines[2])
}
if lines[3] != "with a line break." {
t.Errorf("Expected fourth line to be 'with a line break.', got '%s'", lines[3])
}
}
func TestGenerateFromSubtitle_EmptySubtitle(t *testing.T) {
// Create empty subtitle
subtitle := model.NewSubtitle()
// Generate TXT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "empty.txt")
err := GenerateFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("GenerateFromSubtitle failed with empty subtitle: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content is empty
if len(content) != 0 {
t.Errorf("Expected empty file, got content: %s", string(content))
}
}
func TestGenerateFromSubtitle_WithTitle(t *testing.T) {
// Create subtitle with title
subtitle := model.NewSubtitle()
subtitle.Title = "My Test Title"
entry1 := model.NewSubtitleEntry()
entry1.Text = "This is a test line."
subtitle.Entries = append(subtitle.Entries, entry1)
// Generate TXT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "titled.txt")
err := GenerateFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("GenerateFromSubtitle failed with titled subtitle: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content has title and proper formatting
lines := strings.Split(string(content), "\n")
if len(lines) < 3 { // Title + blank line + content
t.Fatalf("Expected at least 3 lines, got %d", len(lines))
}
if lines[0] != "My Test Title" {
t.Errorf("Expected first line to be title, got '%s'", lines[0])
}
if lines[1] != "" {
t.Errorf("Expected second line to be blank, got '%s'", lines[1])
}
if lines[2] != "This is a test line." {
t.Errorf("Expected third line to be content, got '%s'", lines[2])
}
}
func TestGenerateFromSubtitle_FileError(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
entry1 := model.NewSubtitleEntry()
entry1.Text = "Test line"
subtitle.Entries = append(subtitle.Entries, entry1)
// Test with invalid file path
invalidPath := "/nonexistent/directory/file.txt"
err := GenerateFromSubtitle(subtitle, invalidPath)
// Verify error is returned
if err == nil {
t.Errorf("Expected error for invalid file path, got nil")
}
}

View file

@ -0,0 +1,179 @@
package vtt
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestConvertToSubtitle(t *testing.T) {
// Create a temporary test file
content := `WEBVTT - Test Title
STYLE
::cue {
color: white;
}
NOTE This is a test comment
1
00:00:01.000 --> 00:00:04.000 align:start position:10%
This is <i>styled</i> text.
2
00:00:05.000 --> 00:00:08.000
This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check result
if subtitle.Format != "vtt" {
t.Errorf("Expected format 'vtt', got '%s'", subtitle.Format)
}
if subtitle.Title != "Test Title" {
t.Errorf("Expected title 'Test Title', got '%s'", subtitle.Title)
}
// Check style conversion
if _, ok := subtitle.Styles["css"]; !ok {
t.Errorf("Expected CSS style to be preserved in subtitle.Styles['css'], got: %v", subtitle.Styles)
}
// Check entry count and content
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].Index != 1 {
t.Errorf("First entry index: expected 1, got %d", subtitle.Entries[0].Index)
}
// The VTT parser does not strip HTML tags by default
if subtitle.Entries[0].Text != "This is <i>styled</i> text." {
t.Errorf("First entry text: expected 'This is <i>styled</i> text.', got '%s'", subtitle.Entries[0].Text)
}
if subtitle.Entries[0].Styles["align"] != "start" {
t.Errorf("Expected align style 'start', got '%s'", subtitle.Entries[0].Styles["align"])
}
// 检查 FormatData 中是否记录了 HTML 标签存在
if val, ok := subtitle.Entries[0].FormatData["has_html_tags"]; !ok || val != true {
t.Errorf("Expected FormatData['has_html_tags'] to be true for entry with HTML tags")
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create a subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
subtitle.Title = "Test VTT"
subtitle.Styles = map[string]string{"css": "::cue { color: white; }"}
subtitle.Comments = append(subtitle.Comments, "This is a test comment")
// Create a region
region := model.NewSubtitleRegion("region1")
region.Settings["width"] = "40%"
region.Settings["lines"] = "3"
subtitle.Regions = append(subtitle.Regions, region)
// Create entries
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry1.Styles["region"] = "region1"
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is <i>italic</i> text."
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert to VTT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.vtt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Check header
if !strings.HasPrefix(contentStr, "WEBVTT - Test VTT") {
t.Errorf("Expected header with title in output")
}
// Check style section
if !strings.Contains(contentStr, "STYLE") {
t.Errorf("Expected STYLE section in output")
}
if !strings.Contains(contentStr, "::cue { color: white; }") {
t.Errorf("Expected CSS content in style section")
}
// Check comment
if !strings.Contains(contentStr, "NOTE This is a test comment") {
t.Errorf("Expected comment in output")
}
// Check region
if !strings.Contains(contentStr, "REGION") || !strings.Contains(contentStr, "region1") {
t.Errorf("Expected region definition in output")
}
// Check region applied to first entry
if !strings.Contains(contentStr, "region:region1") {
t.Errorf("Expected region style to be applied to first entry")
}
// Check HTML tags
if !strings.Contains(contentStr, "<i>") || !strings.Contains(contentStr, "</i>") {
t.Errorf("Expected HTML italic tags in second entry")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test with non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.vtt")
if err == nil {
t.Error("Expected error when converting non-existent file, got nil")
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Create simple subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
subtitle.Entries = append(subtitle.Entries, model.NewSubtitleEntry())
// Test with invalid path
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.vtt")
if err == nil {
t.Error("Expected error when converting to invalid path, got nil")
}
}

View file

@ -0,0 +1,78 @@
package vtt
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFormat(t *testing.T) {
// Create a temporary test file with valid VTT content
// 注意格式必须严格符合 WebVTT 规范,否则 Parse 会失败
content := `WEBVTT
1
00:00:01.000 --> 00:00:04.000
This is the first line.
2
00:00:05.000 --> 00:00:08.000 align:center
This is the second line.
3
00:00:09.500 --> 00:00:12.800
This is the third line
with a line break.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Format the file
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Read the formatted file
formatted, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read formatted file: %v", err)
}
// 检查基本的内容是否存在
formattedStr := string(formatted)
// 检查标题行
if !strings.Contains(formattedStr, "WEBVTT") {
t.Errorf("Expected WEBVTT header in output, not found")
}
// 检查内容是否保留
if !strings.Contains(formattedStr, "This is the first line.") {
t.Errorf("Expected 'This is the first line.' in output, not found")
}
if !strings.Contains(formattedStr, "This is the second line.") {
t.Errorf("Expected 'This is the second line.' in output, not found")
}
if !strings.Contains(formattedStr, "This is the third line") {
t.Errorf("Expected 'This is the third line' in output, not found")
}
if !strings.Contains(formattedStr, "with a line break.") {
t.Errorf("Expected 'with a line break.' in output, not found")
}
}
func TestFormat_FileErrors(t *testing.T) {
// Test with non-existent file
err := Format("/nonexistent/file.vtt")
if err == nil {
t.Error("Expected error when formatting non-existent file, got nil")
}
}

View file

@ -0,0 +1,148 @@
package vtt
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestGenerate(t *testing.T) {
// Create a test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
subtitle.Title = "Test VTT"
// Add style section
subtitle.Styles = map[string]string{"css": "::cue { color: white; }"}
// Add comments
subtitle.Comments = append(subtitle.Comments, "This is a test comment")
// Create entries
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is the second line."
entry2.Styles = map[string]string{"align": "center"}
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Generate VTT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.vtt")
err := Generate(subtitle, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
contentStr := string(content)
// Verify header
if !strings.HasPrefix(contentStr, "WEBVTT - Test VTT") {
t.Errorf("Expected header with title, got: %s", strings.Split(contentStr, "\n")[0])
}
// Verify style section
if !strings.Contains(contentStr, "STYLE") {
t.Errorf("Expected STYLE section in output")
}
if !strings.Contains(contentStr, "::cue { color: white; }") {
t.Errorf("Expected CSS content in style section")
}
// Verify comment
if !strings.Contains(contentStr, "NOTE This is a test comment") {
t.Errorf("Expected comment in output")
}
// Verify first entry
if !strings.Contains(contentStr, "00:00:01.000 --> 00:00:04.000") {
t.Errorf("Expected first entry timestamp in output")
}
if !strings.Contains(contentStr, "This is the first line.") {
t.Errorf("Expected first entry text in output")
}
// Verify second entry with style
if !strings.Contains(contentStr, "00:00:05.000 --> 00:00:08.000 align:center") {
t.Errorf("Expected second entry timestamp with align style in output")
}
}
func TestGenerate_WithRegions(t *testing.T) {
// Create a subtitle with regions
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
// Add a region
region := model.NewSubtitleRegion("region1")
region.Settings["width"] = "40%"
region.Settings["lines"] = "3"
region.Settings["regionanchor"] = "0%,100%"
subtitle.Regions = append(subtitle.Regions, region)
// Add an entry using the region
entry := model.NewSubtitleEntry()
entry.Index = 1
entry.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry.Text = "This is a regional cue."
entry.Styles = map[string]string{"region": "region1"}
subtitle.Entries = append(subtitle.Entries, entry)
// Generate VTT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "regions.vtt")
err := Generate(subtitle, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify by reading file content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check if region is included
if !strings.Contains(string(content), "REGION region1:") {
t.Errorf("Expected REGION definition in output")
}
for k, v := range region.Settings {
if !strings.Contains(string(content), fmt.Sprintf("%s=%s", k, v)) {
t.Errorf("Expected region setting '%s=%s' in output", k, v)
}
}
}
func TestGenerate_FileError(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
// Test with invalid path
err := Generate(subtitle, "/nonexistent/directory/file.vtt")
if err == nil {
t.Error("Expected error when generating to invalid path, got nil")
}
}

View file

@ -0,0 +1,215 @@
package vtt
import (
"os"
"path/filepath"
"testing"
)
func TestParse(t *testing.T) {
// Create a temporary test file
content := `WEBVTT
1
00:00:01.000 --> 00:00:04.000
This is the first line.
2
00:00:05.000 --> 00:00:08.000
This is the second line.
3
00:00:09.500 --> 00:00:12.800
This is the third line
with a line break.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
if subtitle.Format != "vtt" {
t.Errorf("Expected format 'vtt', got '%s'", subtitle.Format)
}
if len(subtitle.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].Index != 1 {
t.Errorf("First entry index: expected 1, got %d", subtitle.Entries[0].Index)
}
if subtitle.Entries[0].StartTime.Hours != 0 || subtitle.Entries[0].StartTime.Minutes != 0 ||
subtitle.Entries[0].StartTime.Seconds != 1 || subtitle.Entries[0].StartTime.Milliseconds != 0 {
t.Errorf("First entry start time: expected 00:00:01.000, got %+v", subtitle.Entries[0].StartTime)
}
if subtitle.Entries[0].EndTime.Hours != 0 || subtitle.Entries[0].EndTime.Minutes != 0 ||
subtitle.Entries[0].EndTime.Seconds != 4 || subtitle.Entries[0].EndTime.Milliseconds != 0 {
t.Errorf("First entry end time: expected 00:00:04.000, got %+v", subtitle.Entries[0].EndTime)
}
if subtitle.Entries[0].Text != "This is the first line." {
t.Errorf("First entry text: expected 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
}
// Check third entry with line break
if subtitle.Entries[2].Index != 3 {
t.Errorf("Third entry index: expected 3, got %d", subtitle.Entries[2].Index)
}
expectedText := "This is the third line\nwith a line break."
if subtitle.Entries[2].Text != expectedText {
t.Errorf("Third entry text: expected '%s', got '%s'", expectedText, subtitle.Entries[2].Text)
}
}
func TestParse_WithHeader(t *testing.T) {
// Create a temporary test file with title
content := `WEBVTT - Test Title
1
00:00:01.000 --> 00:00:04.000
This is the first line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify title was extracted
if subtitle.Title != "Test Title" {
t.Errorf("Expected title 'Test Title', got '%s'", subtitle.Title)
}
}
func TestParse_WithStyles(t *testing.T) {
// Create a temporary test file with CSS styling
content := `WEBVTT
STYLE
::cue {
color: white;
background-color: black;
}
1
00:00:01.000 --> 00:00:04.000 align:start position:10%
This is <b>styled</b> text.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// First check if we have entries at all
if len(subtitle.Entries) == 0 {
t.Fatalf("No entries found in parsed subtitle")
}
// Verify styling was captured
if subtitle.Entries[0].Styles == nil {
t.Fatalf("Entry styles map is nil")
}
// Verify HTML tags were detected
if _, ok := subtitle.Entries[0].FormatData["has_html_tags"]; !ok {
t.Errorf("Expected HTML tags to be detected in entry")
}
// Verify cue settings were captured
if subtitle.Entries[0].Styles["align"] != "start" {
t.Errorf("Expected align style 'start', got '%s'", subtitle.Entries[0].Styles["align"])
}
if subtitle.Entries[0].Styles["position"] != "10%" {
t.Errorf("Expected position style '10%%', got '%s'", subtitle.Entries[0].Styles["position"])
}
}
func TestParse_WithComments(t *testing.T) {
// Create a temporary test file with comments
content := `WEBVTT
NOTE This is a comment
NOTE This is another comment
1
00:00:01.000 --> 00:00:04.000
This is the first line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test_comments.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify comments were captured
if len(subtitle.Comments) != 2 {
t.Errorf("Expected 2 comments, got %d", len(subtitle.Comments))
}
if subtitle.Comments[0] != "This is a comment" {
t.Errorf("Expected first comment 'This is a comment', got '%s'", subtitle.Comments[0])
}
if subtitle.Comments[1] != "This is another comment" {
t.Errorf("Expected second comment 'This is another comment', got '%s'", subtitle.Comments[1])
}
}
func TestParse_FileErrors(t *testing.T) {
// Test with empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.vtt")
if err := os.WriteFile(emptyFile, []byte(""), 0644); err != nil {
t.Fatalf("Failed to create empty file: %v", err)
}
_, err := Parse(emptyFile)
if err == nil {
t.Error("Expected error when parsing empty file, got nil")
}
// Test with invalid WEBVTT header
invalidFile := filepath.Join(tempDir, "invalid.vtt")
if err := os.WriteFile(invalidFile, []byte("INVALID HEADER\n\n"), 0644); err != nil {
t.Fatalf("Failed to create invalid file: %v", err)
}
_, err = Parse(invalidFile)
if err == nil {
t.Error("Expected error when parsing file with invalid header, got nil")
}
// Test with non-existent file
_, err = Parse("/nonexistent/file.vtt")
if err == nil {
t.Error("Expected error when parsing non-existent file, got nil")
}
}

View file

@ -0,0 +1,39 @@
package vtt
import (
"fmt"
"testing"
"sub-cli/internal/model"
)
func TestParseVTTTimestamp(t *testing.T) {
testCases := []struct {
input string
expected model.Timestamp
}{
// Standard format
{"00:00:01.000", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}},
// Without leading zeros
{"0:0:1.0", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}},
// Different millisecond formats
{"00:00:01.1", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 100}},
{"00:00:01.12", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 120}},
{"00:00:01.123", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 123}},
// Long milliseconds (should truncate)
{"00:00:01.1234", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 123}},
// Unusual but valid format
{"01:02:03.456", model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 456}},
// Invalid format (should return a zero timestamp)
{"invalid", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0}},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("Timestamp_%s", tc.input), func(t *testing.T) {
result := parseVTTTimestamp(tc.input)
if result != tc.expected {
t.Errorf("parseVTTTimestamp(%s) = %+v, want %+v", tc.input, result, tc.expected)
}
})
}
}

410
internal/format/vtt/vtt.go Normal file
View file

@ -0,0 +1,410 @@
package vtt
import (
"bufio"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
"sub-cli/internal/model"
)
// Constants for VTT format
const (
VTTHeader = "WEBVTT"
)
// Parse parses a WebVTT file into our intermediate Subtitle representation
func Parse(filePath string) (model.Subtitle, error) {
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
// Ensure maps are initialized
if subtitle.Styles == nil {
subtitle.Styles = make(map[string]string)
}
file, err := os.Open(filePath)
if err != nil {
return subtitle, fmt.Errorf("error opening VTT file: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
// First line must be WEBVTT
if !scanner.Scan() {
return subtitle, fmt.Errorf("empty VTT file")
}
header := scanner.Text()
if !strings.HasPrefix(header, VTTHeader) {
return subtitle, fmt.Errorf("invalid VTT file, missing WEBVTT header")
}
// Get metadata from header
if strings.Contains(header, " - ") {
subtitle.Title = strings.TrimSpace(strings.TrimPrefix(header, VTTHeader+" - "))
}
// Process file content
var currentEntry model.SubtitleEntry
var inCue bool
var inStyle bool
var styleBuffer strings.Builder
var cueTextBuffer strings.Builder
lineNum := 0
prevLine := ""
for scanner.Scan() {
lineNum++
line := scanner.Text()
// Check for style blocks
if strings.HasPrefix(line, "STYLE") {
inStyle = true
continue
}
if inStyle {
if strings.TrimSpace(line) == "" {
inStyle = false
subtitle.Styles["css"] = styleBuffer.String()
styleBuffer.Reset()
} else {
styleBuffer.WriteString(line)
styleBuffer.WriteString("\n")
}
continue
}
// Skip empty lines, but handle end of cue
if strings.TrimSpace(line) == "" {
if inCue && cueTextBuffer.Len() > 0 {
// End of a cue
currentEntry.Text = strings.TrimSpace(cueTextBuffer.String())
subtitle.Entries = append(subtitle.Entries, currentEntry)
inCue = false
cueTextBuffer.Reset()
currentEntry = model.SubtitleEntry{} // Reset to zero value
}
continue
}
// Check for NOTE comments
if strings.HasPrefix(line, "NOTE") {
comment := strings.TrimSpace(strings.TrimPrefix(line, "NOTE"))
subtitle.Comments = append(subtitle.Comments, comment)
continue
}
// Check for REGION definitions
if strings.HasPrefix(line, "REGION") {
// Process region definitions if needed
continue
}
// Check for cue timing line
if strings.Contains(line, " --> ") {
inCue = true
// If we already have a populated currentEntry, save it
if currentEntry.Text != "" {
subtitle.Entries = append(subtitle.Entries, currentEntry)
cueTextBuffer.Reset()
}
// Start a new entry
currentEntry = model.NewSubtitleEntry()
// Use the previous line as cue identifier if it's a number
if prevLine != "" && !inCue {
if index, err := strconv.Atoi(strings.TrimSpace(prevLine)); err == nil {
currentEntry.Index = index
}
}
// Parse timestamps
timestamps := strings.Split(line, " --> ")
if len(timestamps) != 2 {
return subtitle, fmt.Errorf("invalid timestamp format at line %d: %s", lineNum, line)
}
startTimeStr := strings.TrimSpace(timestamps[0])
endTimeAndSettings := strings.TrimSpace(timestamps[1])
// Extract cue settings if any
endTimeStr := endTimeAndSettings
settings := ""
if spaceIndex := strings.IndexByte(endTimeAndSettings, ' '); spaceIndex > 0 {
endTimeStr = endTimeAndSettings[:spaceIndex]
settings = endTimeAndSettings[spaceIndex+1:]
}
// Set timestamps
currentEntry.StartTime = parseVTTTimestamp(startTimeStr)
currentEntry.EndTime = parseVTTTimestamp(endTimeStr)
// Initialize the styles map
currentEntry.Styles = make(map[string]string)
currentEntry.FormatData = make(map[string]interface{})
// Parse cue settings
if settings != "" {
settingPairs := strings.Split(settings, " ")
for _, pair := range settingPairs {
if pair == "" {
continue
}
if strings.Contains(pair, ":") {
parts := strings.Split(pair, ":")
if len(parts) == 2 {
currentEntry.Styles[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
} else {
// Handle non-key-value settings if any
currentEntry.FormatData["setting_"+pair] = true
}
}
}
cueTextBuffer.Reset()
continue
}
// If we're in a cue, add the line to the text buffer
if inCue {
if cueTextBuffer.Len() > 0 {
cueTextBuffer.WriteString("\n")
}
cueTextBuffer.WriteString(line)
}
prevLine = line
}
// Don't forget the last entry
if inCue && cueTextBuffer.Len() > 0 {
currentEntry.Text = strings.TrimSpace(cueTextBuffer.String())
subtitle.Entries = append(subtitle.Entries, currentEntry)
}
// Ensure all entries have sequential indices if they don't already
for i := range subtitle.Entries {
if subtitle.Entries[i].Index == 0 {
subtitle.Entries[i].Index = i + 1
}
// Ensure styles map is initialized for all entries
if subtitle.Entries[i].Styles == nil {
subtitle.Entries[i].Styles = make(map[string]string)
}
// Ensure formatData map is initialized for all entries
if subtitle.Entries[i].FormatData == nil {
subtitle.Entries[i].FormatData = make(map[string]interface{})
}
}
if err := scanner.Err(); err != nil {
return subtitle, fmt.Errorf("error reading VTT file: %w", err)
}
// Process cue text to extract styling
processVTTCueTextStyling(&subtitle)
return subtitle, nil
}
// parseVTTTimestamp parses a VTT timestamp string into our Timestamp model
func parseVTTTimestamp(timeStr string) model.Timestamp {
// VTT timestamps format: 00:00:00.000
re := regexp.MustCompile(`(\d+):(\d+):(\d+)\.(\d+)|\d+:(\d+)\.(\d+)`)
matches := re.FindStringSubmatch(timeStr)
var hours, minutes, seconds, milliseconds int
if len(matches) >= 5 && matches[1] != "" {
// Full format: 00:00:00.000
hours, _ = strconv.Atoi(matches[1])
minutes, _ = strconv.Atoi(matches[2])
seconds, _ = strconv.Atoi(matches[3])
msStr := matches[4]
// Ensure milliseconds are treated correctly
switch len(msStr) {
case 1:
milliseconds, _ = strconv.Atoi(msStr + "00")
case 2:
milliseconds, _ = strconv.Atoi(msStr + "0")
case 3:
milliseconds, _ = strconv.Atoi(msStr)
default:
if len(msStr) > 3 {
milliseconds, _ = strconv.Atoi(msStr[:3])
}
}
} else if len(matches) >= 7 && matches[5] != "" {
// Short format: 00:00.000
minutes, _ = strconv.Atoi(matches[5])
seconds, _ = strconv.Atoi(matches[6])
msStr := matches[7]
// Ensure milliseconds are treated correctly
switch len(msStr) {
case 1:
milliseconds, _ = strconv.Atoi(msStr + "00")
case 2:
milliseconds, _ = strconv.Atoi(msStr + "0")
case 3:
milliseconds, _ = strconv.Atoi(msStr)
default:
if len(msStr) > 3 {
milliseconds, _ = strconv.Atoi(msStr[:3])
}
}
} else {
// Try another approach with time.Parse
layout := "15:04:05.000"
t, err := time.Parse(layout, timeStr)
if err == nil {
hours = t.Hour()
minutes = t.Minute()
seconds = t.Second()
milliseconds = t.Nanosecond() / 1000000
}
}
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
// processVTTCueTextStyling processes the cue text to extract styling tags
func processVTTCueTextStyling(subtitle *model.Subtitle) {
for i, entry := range subtitle.Entries {
// Look for basic HTML tags in the text and extract them to styling attributes
text := entry.Text
// Process <b>, <i>, <u>, etc. tags to collect styling information
// For simplicity, we'll just note that styling exists, but we won't modify the text
if strings.Contains(text, "<") && strings.Contains(text, ">") {
entry.FormatData["has_html_tags"] = true
}
// Update the entry
subtitle.Entries[i] = entry
}
}
// Generate generates a WebVTT file from our intermediate Subtitle representation
func Generate(subtitle model.Subtitle, filePath string) error {
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("error creating VTT file: %w", err)
}
defer file.Close()
// Write header
if subtitle.Title != "" {
fmt.Fprintf(file, "%s - %s\n\n", VTTHeader, subtitle.Title)
} else {
fmt.Fprintf(file, "%s\n\n", VTTHeader)
}
// Write styles if any
if cssStyle, ok := subtitle.Styles["css"]; ok && cssStyle != "" {
fmt.Fprintln(file, "STYLE")
fmt.Fprintln(file, cssStyle)
fmt.Fprintln(file)
}
// Write regions if any
for _, region := range subtitle.Regions {
fmt.Fprintf(file, "REGION %s:", region.ID)
for key, value := range region.Settings {
fmt.Fprintf(file, " %s=%s", key, value)
}
fmt.Fprintln(file)
}
// Write comments if any
for _, comment := range subtitle.Comments {
fmt.Fprintf(file, "NOTE %s\n", comment)
}
if len(subtitle.Comments) > 0 {
fmt.Fprintln(file)
}
// Write cues
for i, entry := range subtitle.Entries {
// Write identifier if available
if identifier, ok := entry.Metadata["identifier"]; ok && identifier != "" {
fmt.Fprintln(file, identifier)
} else if entry.Index > 0 {
fmt.Fprintln(file, entry.Index)
} else {
fmt.Fprintln(file, i+1)
}
// Write timestamps and settings
fmt.Fprintf(file, "%s --> %s", formatVTTTimestamp(entry.StartTime), formatVTTTimestamp(entry.EndTime))
// Add cue settings
for key, value := range entry.Styles {
fmt.Fprintf(file, " %s:%s", key, value)
}
fmt.Fprintln(file)
// Write cue text
fmt.Fprintln(file, entry.Text)
fmt.Fprintln(file)
}
return nil
}
// formatVTTTimestamp formats a Timestamp struct as a VTT timestamp string
func formatVTTTimestamp(ts model.Timestamp) string {
return fmt.Sprintf("%02d:%02d:%02d.%03d",
ts.Hours,
ts.Minutes,
ts.Seconds,
ts.Milliseconds)
}
// Format standardizes and formats a VTT file
func Format(filePath string) error {
// Parse the file
subtitle, err := Parse(filePath)
if err != nil {
return fmt.Errorf("error parsing VTT file: %w", err)
}
// Standardize entry numbering
for i := range subtitle.Entries {
subtitle.Entries[i].Index = i + 1
}
// Write back the formatted content
return Generate(subtitle, filePath)
}
// ConvertToSubtitle converts VTT entries to our intermediate Subtitle structure
func ConvertToSubtitle(filePath string) (model.Subtitle, error) {
return Parse(filePath)
}
// ConvertFromSubtitle converts our intermediate Subtitle to VTT format
func ConvertFromSubtitle(subtitle model.Subtitle, filePath string) error {
return Generate(subtitle, filePath)
}

View file

@ -5,7 +5,10 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sub-cli/internal/format/ass"
"sub-cli/internal/format/lrc" "sub-cli/internal/format/lrc"
"sub-cli/internal/format/srt"
"sub-cli/internal/format/vtt"
) )
// Format formats a subtitle file to ensure consistent formatting // Format formats a subtitle file to ensure consistent formatting
@ -15,6 +18,12 @@ func Format(filePath string) error {
switch ext { switch ext {
case "lrc": case "lrc":
return lrc.Format(filePath) return lrc.Format(filePath)
case "srt":
return srt.Format(filePath)
case "vtt":
return vtt.Format(filePath)
case "ass":
return ass.Format(filePath)
default: default:
return fmt.Errorf("unsupported format for formatting: %s", ext) return fmt.Errorf("unsupported format for formatting: %s", ext)
} }

View file

@ -0,0 +1,199 @@
package formatter
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFormat(t *testing.T) {
// Create temporary test directory
tempDir := t.TempDir()
// Test cases for different formats
testCases := []struct {
name string
content string
fileExt string
expectedError bool
validateOutput func(t *testing.T, filePath string)
}{
{
name: "SRT Format",
content: `2
00:00:05,000 --> 00:00:08,000
This is the second line.
1
00:00:01,000 --> 00:00:04,000
This is the first line.
`,
fileExt: "srt",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Check that entries are numbered correctly - don't assume ordering by timestamp
// The format function should renumber cues sequentially, but might not change order
if !strings.Contains(contentStr, "1\n00:00:") && !strings.Contains(contentStr, "2\n00:00:") {
t.Errorf("Output should contain numbered entries (1 and 2), got: %s", contentStr)
}
// Check content preservation
if !strings.Contains(contentStr, "This is the first line.") ||
!strings.Contains(contentStr, "This is the second line.") {
t.Errorf("Output should preserve all content")
}
},
},
{
name: "LRC Format",
content: `[ar:Test Artist]
[00:05.00]This is the second line.
[00:01.0]This is the first line.
`,
fileExt: "lrc",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Check that timestamps are standardized (HH:MM:SS.mmm)
if !strings.Contains(contentStr, "[00:01.000]") {
t.Errorf("Expected standardized timestamp [00:01.000], got: %s", contentStr)
}
if !strings.Contains(contentStr, "[00:05.000]") {
t.Errorf("Expected standardized timestamp [00:05.000], got: %s", contentStr)
}
// Check metadata is preserved
if !strings.Contains(contentStr, "[ar:Test Artist]") {
t.Errorf("Expected metadata [ar:Test Artist] to be preserved, got: %s", contentStr)
}
},
},
{
name: "VTT Format",
content: `WEBVTT
10
00:00:05.000 --> 00:00:08.000
This is the second line.
5
00:00:01.000 --> 00:00:04.000
This is the first line.
`,
fileExt: "vtt",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Check that cues are numbered correctly - don't assume ordering by timestamp
// Just check that identifiers are sequential
if !strings.Contains(contentStr, "1\n00:00:") && !strings.Contains(contentStr, "2\n00:00:") {
t.Errorf("Output should contain sequential identifiers (1 and 2), got: %s", contentStr)
}
// Check content preservation
if !strings.Contains(contentStr, "This is the first line.") ||
!strings.Contains(contentStr, "This is the second line.") {
t.Errorf("Output should preserve all content")
}
},
},
{
name: "Unsupported Format",
content: "Some content",
fileExt: "txt",
expectedError: true,
validateOutput: nil,
},
}
// Run test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create test file
testFile := filepath.Join(tempDir, "test."+tc.fileExt)
if err := os.WriteFile(testFile, []byte(tc.content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Call Format
err := Format(testFile)
// Check error
if tc.expectedError && err == nil {
t.Errorf("Expected error but got none")
}
if !tc.expectedError && err != nil {
t.Errorf("Expected no error but got: %v", err)
}
// If no error expected and validation function provided, validate output
if !tc.expectedError && tc.validateOutput != nil {
tc.validateOutput(t, testFile)
}
})
}
}
func TestFormat_NonExistentFile(t *testing.T) {
tempDir := t.TempDir()
nonExistentFile := filepath.Join(tempDir, "nonexistent.srt")
err := Format(nonExistentFile)
if err == nil {
t.Errorf("Expected error when file doesn't exist, but got none")
}
}
func TestFormat_PermissionError(t *testing.T) {
// This test might not be applicable on all platforms
// Skip it if running on a platform where permissions can't be enforced
if os.Getenv("SKIP_PERMISSION_TESTS") != "" {
t.Skip("Skipping permission test")
}
// Create temporary directory
tempDir := t.TempDir()
// Create test file in the temporary directory
testFile := filepath.Join(tempDir, "test.srt")
content := `1
00:00:01,000 --> 00:00:04,000
This is a test line.
`
// Write the file
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Make file read-only
if err := os.Chmod(testFile, 0400); err != nil {
t.Skipf("Failed to change file permissions, skipping test: %v", err)
}
// Try to format read-only file
err := Format(testFile)
if err == nil {
t.Errorf("Expected error when formatting read-only file, but got none")
}
}

View file

@ -22,3 +22,130 @@ type SRTEntry struct {
EndTime Timestamp EndTime Timestamp
Content string Content string
} }
// SubtitleEntry represents a generic subtitle entry in our intermediate representation
type SubtitleEntry struct {
Index int // Sequential index/number
StartTime Timestamp // Start time
EndTime Timestamp // End time
Text string // The subtitle text content
Styles map[string]string // Styling information (e.g., VTT's align, position)
Classes []string // CSS classes (for VTT)
Metadata map[string]string // Additional metadata
FormatData map[string]interface{} // Format-specific data that doesn't fit elsewhere
}
// Subtitle represents our intermediate subtitle representation used for conversions
type Subtitle struct {
Title string // Optional title
Metadata map[string]string // Global metadata (e.g., LRC's ti, ar, al)
Entries []SubtitleEntry // Subtitle entries
Format string // Source format
Styles map[string]string // Global styles (e.g., VTT STYLE blocks)
Comments []string // Comments/notes (for VTT)
Regions []SubtitleRegion // Region definitions (for VTT)
FormatData map[string]interface{} // Format-specific data that doesn't fit elsewhere
}
// SubtitleRegion represents a region definition (mainly for VTT)
type SubtitleRegion struct {
ID string
Settings map[string]string
}
// ASSEvent represents an event entry in an ASS file (dialogue, comment, etc.)
type ASSEvent struct {
Type string // Dialogue, Comment, etc.
Layer int // Layer number (0-based)
StartTime Timestamp // Start time
EndTime Timestamp // End time
Style string // Style name
Name string // Character name
MarginL int // Left margin override
MarginR int // Right margin override
MarginV int // Vertical margin override
Effect string // Transition effect
Text string // The actual text
}
// ASSStyle represents a style definition in an ASS file
type ASSStyle struct {
Name string // Style name
Properties map[string]string // Font name, size, colors, etc.
}
// ASSFile represents an Advanced SubStation Alpha (ASS) file
type ASSFile struct {
ScriptInfo map[string]string // Format, Title, ScriptType, etc.
Styles []ASSStyle // Style definitions
Events []ASSEvent // Dialogue lines
}
// Creates a new empty Subtitle
func NewSubtitle() Subtitle {
return Subtitle{
Metadata: make(map[string]string),
Entries: []SubtitleEntry{},
Styles: make(map[string]string),
Comments: []string{},
Regions: []SubtitleRegion{},
FormatData: make(map[string]interface{}),
}
}
// Creates a new empty SubtitleEntry
func NewSubtitleEntry() SubtitleEntry {
return SubtitleEntry{
Styles: make(map[string]string),
Classes: []string{},
Metadata: make(map[string]string),
FormatData: make(map[string]interface{}),
}
}
// Creates a new SubtitleRegion
func NewSubtitleRegion(id string) SubtitleRegion {
return SubtitleRegion{
ID: id,
Settings: make(map[string]string),
}
}
// NewASSFile creates a new empty ASS file structure with minimal defaults
func NewASSFile() ASSFile {
// Create minimal defaults for a valid ASS file
scriptInfo := map[string]string{
"ScriptType": "v4.00+",
"Collisions": "Normal",
"PlayResX": "640",
"PlayResY": "480",
"Timer": "100.0000",
}
// Create a default style
defaultStyle := ASSStyle{
Name: "Default",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
},
}
return ASSFile{
ScriptInfo: scriptInfo,
Styles: []ASSStyle{defaultStyle},
Events: []ASSEvent{},
}
}
// NewASSEvent creates a new ASS event with default values
func NewASSEvent() ASSEvent {
return ASSEvent{
Type: "Dialogue",
Layer: 0,
Style: "Default",
MarginL: 0,
MarginR: 0,
MarginV: 0,
}
}

View file

@ -0,0 +1,215 @@
package model
import (
"testing"
"strings"
)
func TestNewSubtitle(t *testing.T) {
subtitle := NewSubtitle()
if subtitle.Format != "" {
t.Errorf("Expected empty format, got %s", subtitle.Format)
}
if subtitle.Title != "" {
t.Errorf("Expected empty title, got %s", subtitle.Title)
}
if len(subtitle.Entries) != 0 {
t.Errorf("Expected 0 entries, got %d", len(subtitle.Entries))
}
if subtitle.Metadata == nil {
t.Error("Expected metadata map to be initialized")
}
if subtitle.Styles == nil {
t.Error("Expected styles map to be initialized")
}
}
func TestNewSubtitleEntry(t *testing.T) {
entry := NewSubtitleEntry()
if entry.Index != 0 {
t.Errorf("Expected index 0, got %d", entry.Index)
}
if entry.StartTime.Hours != 0 || entry.StartTime.Minutes != 0 ||
entry.StartTime.Seconds != 0 || entry.StartTime.Milliseconds != 0 {
t.Errorf("Expected zero start time, got %+v", entry.StartTime)
}
if entry.EndTime.Hours != 0 || entry.EndTime.Minutes != 0 ||
entry.EndTime.Seconds != 0 || entry.EndTime.Milliseconds != 0 {
t.Errorf("Expected zero end time, got %+v", entry.EndTime)
}
if entry.Text != "" {
t.Errorf("Expected empty text, got %s", entry.Text)
}
if entry.Metadata == nil {
t.Error("Expected metadata map to be initialized")
}
if entry.Styles == nil {
t.Error("Expected styles map to be initialized")
}
if entry.FormatData == nil {
t.Error("Expected formatData map to be initialized")
}
if entry.Classes == nil {
t.Error("Expected classes slice to be initialized")
}
}
func TestNewSubtitleRegion(t *testing.T) {
// Test with empty ID
region := NewSubtitleRegion("")
if region.ID != "" {
t.Errorf("Expected empty ID, got %s", region.ID)
}
if region.Settings == nil {
t.Error("Expected settings map to be initialized")
}
// Test with a specific ID
testID := "region1"
region = NewSubtitleRegion(testID)
if region.ID != testID {
t.Errorf("Expected ID %s, got %s", testID, region.ID)
}
// Verify the settings map is initialized and can store values
region.Settings["width"] = "100%"
region.Settings["lines"] = "3"
if val, ok := region.Settings["width"]; !ok || val != "100%" {
t.Errorf("Expected settings to contain width=100%%, got %s", val)
}
if val, ok := region.Settings["lines"]; !ok || val != "3" {
t.Errorf("Expected settings to contain lines=3, got %s", val)
}
}
func TestNewASSFile(t *testing.T) {
assFile := NewASSFile()
// Test that script info is initialized with defaults
if assFile.ScriptInfo == nil {
t.Error("Expected ScriptInfo map to be initialized")
}
// Check default script info values
expectedDefaults := map[string]string{
"ScriptType": "v4.00+",
"Collisions": "Normal",
"PlayResX": "640",
"PlayResY": "480",
"Timer": "100.0000",
}
for key, expectedValue := range expectedDefaults {
if value, exists := assFile.ScriptInfo[key]; !exists || value != expectedValue {
t.Errorf("Expected default ScriptInfo[%s] = %s, got %s", key, expectedValue, value)
}
}
// Test that styles are initialized
if assFile.Styles == nil {
t.Error("Expected Styles slice to be initialized")
}
// Test that at least the Default style exists
if len(assFile.Styles) < 1 {
t.Error("Expected at least Default style to be created")
} else {
defaultStyleFound := false
for _, style := range assFile.Styles {
if style.Name == "Default" {
defaultStyleFound = true
// Check the style properties of the default style
styleStr, exists := style.Properties["Style"]
if !exists {
t.Error("Expected Default style to have a Style property, but it wasn't found")
} else if !strings.Contains(styleStr, ",0,0,0,0,") { // Check that Bold, Italic, Underline, StrikeOut are all 0
t.Errorf("Expected Default style to have Bold/Italic/Underline/StrikeOut set to 0, got: %s", styleStr)
}
break
}
}
if !defaultStyleFound {
t.Error("Expected to find a Default style")
}
}
// Test that events are initialized as an empty slice
if assFile.Events == nil {
t.Error("Expected Events slice to be initialized")
}
if len(assFile.Events) != 0 {
t.Errorf("Expected 0 events, got %d", len(assFile.Events))
}
}
func TestNewASSEvent(t *testing.T) {
event := NewASSEvent()
// Test default type
if event.Type != "Dialogue" {
t.Errorf("Expected Type to be 'Dialogue', got '%s'", event.Type)
}
// Test default layer
if event.Layer != 0 {
t.Errorf("Expected Layer to be 0, got %d", event.Layer)
}
// Test default style
if event.Style != "Default" {
t.Errorf("Expected Style to be 'Default', got '%s'", event.Style)
}
// Test default name
if event.Name != "" {
t.Errorf("Expected Name to be empty, got '%s'", event.Name)
}
// Test default margins
if event.MarginL != 0 || event.MarginR != 0 || event.MarginV != 0 {
t.Errorf("Expected all margins to be 0, got L:%d, R:%d, V:%d",
event.MarginL, event.MarginR, event.MarginV)
}
// Test default effect
if event.Effect != "" {
t.Errorf("Expected Effect to be empty, got '%s'", event.Effect)
}
// Test default text
if event.Text != "" {
t.Errorf("Expected Text to be empty, got '%s'", event.Text)
}
// Test start and end times
zeroTime := Timestamp{}
if event.StartTime != zeroTime {
t.Errorf("Expected start time to be zero, got %+v", event.StartTime)
}
if event.EndTime != zeroTime {
t.Errorf("Expected end time to be zero, got %+v", event.EndTime)
}
}

80
internal/sync/ass.go Normal file
View file

@ -0,0 +1,80 @@
package sync
import (
"fmt"
"sub-cli/internal/format/ass"
"sub-cli/internal/model"
)
// syncASSFiles synchronizes two ASS files
func syncASSFiles(sourceFile, targetFile string) error {
sourceSubtitle, err := ass.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source ASS file: %w", err)
}
targetSubtitle, err := ass.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target ASS file: %w", err)
}
// Check if entry counts match
if len(sourceSubtitle.Events) != len(targetSubtitle.Events) {
fmt.Printf("Warning: Source (%d events) and target (%d events) have different event counts. Timeline will be adjusted.\n",
len(sourceSubtitle.Events), len(targetSubtitle.Events))
}
// Sync the timelines
syncedSubtitle := syncASSTimeline(sourceSubtitle, targetSubtitle)
// Write the synced subtitle to the target file
return ass.Generate(syncedSubtitle, targetFile)
}
// syncASSTimeline applies the timing from source ASS subtitle to target ASS subtitle
func syncASSTimeline(source, target model.ASSFile) model.ASSFile {
result := model.ASSFile{
ScriptInfo: target.ScriptInfo,
Styles: target.Styles,
Events: make([]model.ASSEvent, len(target.Events)),
}
// Copy target events
copy(result.Events, target.Events)
// If there are no events in either source or target, return as is
if len(source.Events) == 0 || len(target.Events) == 0 {
return result
}
// Extract start and end timestamps from source
sourceStartTimes := make([]model.Timestamp, len(source.Events))
sourceEndTimes := make([]model.Timestamp, len(source.Events))
for i, event := range source.Events {
sourceStartTimes[i] = event.StartTime
sourceEndTimes[i] = event.EndTime
}
// Scale timestamps if source and target event counts differ
var scaledStartTimes, scaledEndTimes []model.Timestamp
if len(source.Events) == len(target.Events) {
// If counts match, use source times directly
scaledStartTimes = sourceStartTimes
scaledEndTimes = sourceEndTimes
} else {
// Scale the timelines to match target count
scaledStartTimes = scaleTimeline(sourceStartTimes, len(target.Events))
scaledEndTimes = scaleTimeline(sourceEndTimes, len(target.Events))
}
// Apply scaled timeline to target events
for i := range result.Events {
result.Events[i].StartTime = scaledStartTimes[i]
result.Events[i].EndTime = scaledEndTimes[i]
}
return result
}

465
internal/sync/ass_test.go Normal file
View file

@ -0,0 +1,465 @@
package sync
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestSyncASSTimeline(t *testing.T) {
testCases := []struct {
name string
source model.ASSFile
target model.ASSFile
verify func(t *testing.T, result model.ASSFile)
}{
{
name: "Equal event counts",
source: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Source ASS"},
Styles: []model.ASSStyle{
{
Name: "Default",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour",
"Style": "Default,Arial,20,&H00FFFFFF",
},
},
},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Text: "Source line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Style: "Default",
Text: "Source line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Style: "Default",
Text: "Source line three.",
},
},
},
target: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Target ASS"},
Styles: []model.ASSStyle{
{
Name: "Default",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour",
"Style": "Default,Arial,20,&H00FFFFFF",
},
},
{
Name: "Alternate",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour",
"Style": "Alternate,Times New Roman,20,&H0000FFFF",
},
},
},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Style: "Default",
Text: "Target line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Style: "Alternate",
Text: "Target line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Style: "Default",
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 3 {
t.Errorf("Expected 3 events, got %d", len(result.Events))
return
}
// Check that source timings are applied to target events
if result.Events[0].StartTime.Seconds != 1 || result.Events[0].EndTime.Seconds != 4 {
t.Errorf("First event timing mismatch: got %+v", result.Events[0])
}
if result.Events[1].StartTime.Seconds != 5 || result.Events[1].EndTime.Seconds != 8 {
t.Errorf("Second event timing mismatch: got %+v", result.Events[1])
}
if result.Events[2].StartTime.Seconds != 9 || result.Events[2].EndTime.Seconds != 12 {
t.Errorf("Third event timing mismatch: got %+v", result.Events[2])
}
// Check that target content and styles are preserved
if result.Events[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Events[0].Text)
}
if result.Events[1].Style != "Alternate" {
t.Errorf("Style should be preserved, got: %s", result.Events[1].Style)
}
// Check that script info and style definitions are preserved
if result.ScriptInfo["Title"] != "Target ASS" {
t.Errorf("ScriptInfo should be preserved, got: %+v", result.ScriptInfo)
}
if len(result.Styles) != 2 {
t.Errorf("Expected 2 styles, got %d", len(result.Styles))
}
if result.Styles[1].Name != "Alternate" {
t.Errorf("Style definitions should be preserved, got: %+v", result.Styles[1])
}
},
},
{
name: "More target events than source",
source: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Text: "Source line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Style: "Default",
Text: "Source line two.",
},
},
},
target: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Style: "Default",
Text: "Target line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Style: "Default",
Text: "Target line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Style: "Default",
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 3 {
t.Errorf("Expected 3 events, got %d", len(result.Events))
return
}
// First event should use first source timing
if result.Events[0].StartTime.Seconds != 1 {
t.Errorf("First event should have first source timing, got: %+v", result.Events[0].StartTime)
}
// Last event should use last source timing
if result.Events[2].StartTime.Seconds != 5 {
t.Errorf("Last event should have last source timing, got: %+v", result.Events[2].StartTime)
}
// Verify content is preserved
if result.Events[2].Text != "Target line three." {
t.Errorf("Content should be preserved, got: %s", result.Events[2].Text)
}
},
},
{
name: "More source events than target",
source: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 3},
Style: "Default",
Text: "Source line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 4},
EndTime: model.Timestamp{Seconds: 6},
Style: "Default",
Text: "Source line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 7},
EndTime: model.Timestamp{Seconds: 9},
Style: "Default",
Text: "Source line three.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 12},
Style: "Default",
Text: "Source line four.",
},
},
},
target: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Style: "Default",
Text: "Target line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Style: "Default",
Text: "Target line two.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 2 {
t.Errorf("Expected 2 events, got %d", len(result.Events))
return
}
// First event should have first source timing
if result.Events[0].StartTime.Seconds != 1 {
t.Errorf("First event should have first source timing, got: %+v", result.Events[0].StartTime)
}
// Last event should have last source timing
if result.Events[1].StartTime.Seconds != 10 {
t.Errorf("Last event should have last source timing, got: %+v", result.Events[1].StartTime)
}
// Check that target content is preserved
if result.Events[0].Text != "Target line one." || result.Events[1].Text != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result.Events)
}
},
},
{
name: "Empty target events",
source: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Text: "Source line one.",
},
},
},
target: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Empty Target"},
Events: []model.ASSEvent{},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 0 {
t.Errorf("Expected 0 events, got %d", len(result.Events))
}
// ScriptInfo should be preserved
if result.ScriptInfo["Title"] != "Empty Target" {
t.Errorf("ScriptInfo should be preserved, got: %+v", result.ScriptInfo)
}
},
},
{
name: "Empty source events",
source: model.ASSFile{
Events: []model.ASSEvent{},
},
target: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Target with content"},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 15},
Style: "Default",
Text: "Target line one.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 1 {
t.Errorf("Expected 1 event, got %d", len(result.Events))
return
}
// Timing should be preserved since source is empty
if result.Events[0].StartTime.Seconds != 10 || result.Events[0].EndTime.Seconds != 15 {
t.Errorf("Timing should match target when source is empty, got: %+v", result.Events[0])
}
// Content should be preserved
if result.Events[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Events[0].Text)
}
// Title should be preserved
if result.ScriptInfo["Title"] != "Target with content" {
t.Errorf("Title should be preserved, got: %+v", result.ScriptInfo)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncASSTimeline(tc.source, tc.target)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}
func TestSyncASSFiles(t *testing.T) {
// Create temporary test directory
tempDir := t.TempDir()
// Test case for testing the sync of ASS files
sourceContent := `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Timer: 100.0000
Title: Source ASS File
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,Source line one.
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,Source line two.
Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,Source line three.
`
targetContent := `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Timer: 100.0000
Title: Target ASS File
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Alternate,Arial,20,&H0000FFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:01:00.00,0:01:03.00,Default,,0,0,0,,Target line one.
Dialogue: 0,0:01:05.00,0:01:08.00,Alternate,,0,0,0,,Target line two.
Dialogue: 0,0:01:10.00,0:01:13.00,Default,,0,0,0,,Target line three.
`
sourceFile := filepath.Join(tempDir, "source.ass")
targetFile := filepath.Join(tempDir, "target.ass")
// Write test files
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
t.Fatalf("Failed to write source file: %v", err)
}
if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil {
t.Fatalf("Failed to write target file: %v", err)
}
// Run syncASSFiles
err := syncASSFiles(sourceFile, targetFile)
if err != nil {
t.Fatalf("syncASSFiles returned error: %v", err)
}
// Read the modified target file
modifiedContent, err := os.ReadFile(targetFile)
if err != nil {
t.Fatalf("Failed to read modified file: %v", err)
}
// Verify the result
// Should have source timings
if !strings.Contains(string(modifiedContent), "0:00:01.00") {
t.Errorf("Output should have source timing 0:00:01.00, got: %s", string(modifiedContent))
}
// Should preserve target content and styles
if !strings.Contains(string(modifiedContent), "Target line one.") {
t.Errorf("Output should preserve target content, got: %s", string(modifiedContent))
}
if !strings.Contains(string(modifiedContent), "Style: Alternate") {
t.Errorf("Output should preserve target styles, got: %s", string(modifiedContent))
}
// Should preserve title
if !strings.Contains(string(modifiedContent), "Title: Target ASS File") {
t.Errorf("Output should preserve target title, got: %s", string(modifiedContent))
}
}

64
internal/sync/lrc.go Normal file
View file

@ -0,0 +1,64 @@
package sync
import (
"fmt"
"sub-cli/internal/format/lrc"
"sub-cli/internal/model"
)
// syncLRCFiles synchronizes two LRC files
func syncLRCFiles(sourceFile, targetFile string) error {
source, err := lrc.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source file: %w", err)
}
target, err := lrc.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target file: %w", err)
}
// Check if line counts match
if len(source.Timeline) != len(target.Content) {
fmt.Printf("Warning: Source timeline (%d entries) and target content (%d entries) have different counts. Timeline will be scaled.\n",
len(source.Timeline), len(target.Content))
}
// Apply timeline from source to target
syncedLyrics := syncLRCTimeline(source, target)
// Write the synced lyrics to the target file
return lrc.Generate(syncedLyrics, targetFile)
}
// syncLRCTimeline applies the timeline from the source lyrics to the target lyrics
func syncLRCTimeline(source, target model.Lyrics) model.Lyrics {
result := model.Lyrics{
Metadata: target.Metadata,
Content: target.Content,
}
// If target has no content, return empty result with metadata only
if len(target.Content) == 0 {
result.Timeline = []model.Timestamp{}
return result
}
// If source has no timeline, keep target as is
if len(source.Timeline) == 0 {
result.Timeline = target.Timeline
return result
}
// Scale the source timeline to match the target content length
if len(source.Timeline) != len(target.Content) {
result.Timeline = scaleTimeline(source.Timeline, len(target.Content))
} else {
// If lengths match, directly use source timeline
result.Timeline = make([]model.Timestamp, len(source.Timeline))
copy(result.Timeline, source.Timeline)
}
return result
}

265
internal/sync/lrc_test.go Normal file
View file

@ -0,0 +1,265 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestSyncLRCTimeline(t *testing.T) {
testCases := []struct {
name string
source model.Lyrics
target model.Lyrics
verify func(t *testing.T, result model.Lyrics)
}{
{
name: "Equal content length",
source: model.Lyrics{
Metadata: map[string]string{
"ti": "Source LRC",
"ar": "Test Artist",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
{Minutes: 0, Seconds: 5, Milliseconds: 0},
{Minutes: 0, Seconds: 9, Milliseconds: 500},
},
Content: []string{
"This is line one.",
"This is line two.",
"This is line three.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target LRC",
"ar": "Different Artist",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
{Minutes: 0, Seconds: 20, Milliseconds: 0},
{Minutes: 0, Seconds: 30, Milliseconds: 0},
},
Content: []string{
"This is line one with different timing.",
"This is line two with different timing.",
"This is line three with different timing.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 3 {
t.Errorf("Expected 3 timeline entries, got %d", len(result.Timeline))
return
}
// Verify that source timings are applied
if result.Timeline[0].Seconds != 1 || result.Timeline[0].Milliseconds != 0 {
t.Errorf("First timeline entry should have source timing, got: %+v", result.Timeline[0])
}
if result.Timeline[1].Seconds != 5 || result.Timeline[1].Milliseconds != 0 {
t.Errorf("Second timeline entry should have source timing, got: %+v", result.Timeline[1])
}
if result.Timeline[2].Seconds != 9 || result.Timeline[2].Milliseconds != 500 {
t.Errorf("Third timeline entry should have source timing, got: %+v", result.Timeline[2])
}
// Verify that target content is preserved
if result.Content[0] != "This is line one with different timing." {
t.Errorf("Content should be preserved, got: %s", result.Content[0])
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Target LRC" || result.Metadata["ar"] != "Different Artist" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
{
name: "More target content than source timeline",
source: model.Lyrics{
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
{Minutes: 0, Seconds: 5, Milliseconds: 0},
},
Content: []string{
"This is line one.",
"This is line two.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target LRC",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
{Minutes: 0, Seconds: 20, Milliseconds: 0},
{Minutes: 0, Seconds: 30, Milliseconds: 0},
},
Content: []string{
"This is line one with different timing.",
"This is line two with different timing.",
"This is line three with different timing.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 3 {
t.Errorf("Expected 3 timeline entries, got %d", len(result.Timeline))
return
}
// Verify that source timings are scaled
if result.Timeline[0].Seconds != 1 {
t.Errorf("First timeline entry should have first source timing, got: %+v", result.Timeline[0])
}
if result.Timeline[2].Seconds != 5 {
t.Errorf("Last timeline entry should have last source timing, got: %+v", result.Timeline[2])
}
// Verify that target content is preserved
if result.Content[2] != "This is line three with different timing." {
t.Errorf("Content should be preserved, got: %s", result.Content[2])
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Target LRC" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
{
name: "More source timeline than target content",
source: model.Lyrics{
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
{Minutes: 0, Seconds: 3, Milliseconds: 0},
{Minutes: 0, Seconds: 5, Milliseconds: 0},
{Minutes: 0, Seconds: 7, Milliseconds: 0},
},
Content: []string{
"Source line one.",
"Source line two.",
"Source line three.",
"Source line four.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target LRC",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
{Minutes: 0, Seconds: 20, Milliseconds: 0},
},
Content: []string{
"Target line one.",
"Target line two.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 2 {
t.Errorf("Expected 2 timeline entries, got %d", len(result.Timeline))
return
}
// Verify that source timings are scaled
if result.Timeline[0].Seconds != 1 {
t.Errorf("First timeline entry should have first source timing, got: %+v", result.Timeline[0])
}
if result.Timeline[1].Seconds != 7 {
t.Errorf("Last timeline entry should have last source timing, got: %+v", result.Timeline[1])
}
// Verify that target content is preserved
if result.Content[0] != "Target line one." || result.Content[1] != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result.Content)
}
},
},
{
name: "Empty target content",
source: model.Lyrics{
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
},
Content: []string{
"Source line one.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Empty Target",
},
Timeline: []model.Timestamp{},
Content: []string{},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 0 {
t.Errorf("Expected 0 timeline entries, got %d", len(result.Timeline))
}
if len(result.Content) != 0 {
t.Errorf("Expected 0 content entries, got %d", len(result.Content))
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Empty Target" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
{
name: "Empty source timeline",
source: model.Lyrics{
Timeline: []model.Timestamp{},
Content: []string{},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target with content",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
},
Content: []string{
"Target line one.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 1 {
t.Errorf("Expected 1 timeline entry, got %d", len(result.Timeline))
return
}
// Verify that target timing is preserved when source is empty
if result.Timeline[0].Seconds != 10 {
t.Errorf("Timeline should match target when source is empty, got: %+v", result.Timeline[0])
}
// Verify that target content is preserved
if result.Content[0] != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Content[0])
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Target with content" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncLRCTimeline(tc.source, tc.target)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}

100
internal/sync/srt.go Normal file
View file

@ -0,0 +1,100 @@
package sync
import (
"fmt"
"sub-cli/internal/format/srt"
"sub-cli/internal/model"
)
// syncSRTFiles synchronizes two SRT files
func syncSRTFiles(sourceFile, targetFile string) error {
sourceEntries, err := srt.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source SRT file: %w", err)
}
targetEntries, err := srt.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target SRT file: %w", err)
}
// Check if entry counts match
if len(sourceEntries) != len(targetEntries) {
fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n",
len(sourceEntries), len(targetEntries))
}
// Sync the timelines
syncedEntries := syncSRTTimeline(sourceEntries, targetEntries)
// Write the synced entries to the target file
return srt.Generate(syncedEntries, targetFile)
}
// syncSRTTimeline applies the timing from source SRT entries to target SRT entries
func syncSRTTimeline(sourceEntries, targetEntries []model.SRTEntry) []model.SRTEntry {
result := make([]model.SRTEntry, len(targetEntries))
// Copy target entries
copy(result, targetEntries)
// If source is empty, just return the target entries as is
if len(sourceEntries) == 0 {
// Ensure proper sequence numbering
for i := range result {
result[i].Number = i + 1
}
return result
}
// If source and target have the same number of entries, directly apply timings
if len(sourceEntries) == len(targetEntries) {
for i := range result {
result[i].StartTime = sourceEntries[i].StartTime
result[i].EndTime = sourceEntries[i].EndTime
}
} else {
// If entry counts differ, scale the timing
for i := range result {
// Calculate scaled index
sourceIdx := 0
if len(sourceEntries) > 1 {
sourceIdx = i * (len(sourceEntries) - 1) / (len(targetEntries) - 1)
}
// Ensure the index is within bounds
if sourceIdx >= len(sourceEntries) {
sourceIdx = len(sourceEntries) - 1
}
// Apply the scaled timing
result[i].StartTime = sourceEntries[sourceIdx].StartTime
// Calculate end time: if not the last entry, use duration from source
if i < len(result)-1 {
// If next source entry exists, calculate duration
var duration model.Timestamp
if sourceIdx+1 < len(sourceEntries) {
duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx+1].StartTime)
} else {
// If no next source entry, use the source's end time (usually a few seconds after start)
duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx].EndTime)
}
// Apply duration to next start time
result[i].EndTime = addDuration(result[i].StartTime, duration)
} else {
// For the last entry, add a fixed duration (e.g., 3 seconds)
result[i].EndTime = sourceEntries[sourceIdx].EndTime
}
}
}
// Ensure proper sequence numbering
for i := range result {
result[i].Number = i + 1
}
return result
}

274
internal/sync/srt_test.go Normal file
View file

@ -0,0 +1,274 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestSyncSRTTimeline(t *testing.T) {
testCases := []struct {
name string
sourceEntries []model.SRTEntry
targetEntries []model.SRTEntry
verify func(t *testing.T, result []model.SRTEntry)
}{
{
name: "Equal entry counts",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Content: "Source line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Content: "Source line three.",
},
},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Content: "Target line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Content: "Target line three.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result))
return
}
// Check that source timings are applied to target entries
if result[0].StartTime.Seconds != 1 || result[0].EndTime.Seconds != 4 {
t.Errorf("First entry timing mismatch: got %+v", result[0])
}
if result[1].StartTime.Seconds != 5 || result[1].EndTime.Seconds != 8 {
t.Errorf("Second entry timing mismatch: got %+v", result[1])
}
if result[2].StartTime.Seconds != 9 || result[2].EndTime.Seconds != 12 {
t.Errorf("Third entry timing mismatch: got %+v", result[2])
}
// Check that target content is preserved
if result[0].Content != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result[0].Content)
}
// Check that numbering is correct
if result[0].Number != 1 || result[1].Number != 2 || result[2].Number != 3 {
t.Errorf("Entry numbers should be sequential: %+v", result)
}
},
},
{
name: "More target entries than source",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Content: "Source line two.",
},
},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Content: "Target line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Content: "Target line three.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result))
return
}
// Check that source timings are scaled appropriately
if result[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source start time, got: %+v", result[0].StartTime)
}
if result[2].StartTime.Seconds != 5 {
t.Errorf("Last entry should have last source start time, got: %+v", result[2].StartTime)
}
// Check that content is preserved
if result[2].Content != "Target line three." {
t.Errorf("Content should be preserved, got: %s", result[2].Content)
}
// Check that numbering is correct
if result[0].Number != 1 || result[1].Number != 2 || result[2].Number != 3 {
t.Errorf("Entry numbers should be sequential: %+v", result)
}
},
},
{
name: "More source entries than target",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 3},
Content: "Source line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Seconds: 4},
EndTime: model.Timestamp{Seconds: 6},
Content: "Source line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Seconds: 7},
EndTime: model.Timestamp{Seconds: 9},
Content: "Source line three.",
},
{
Number: 4,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 12},
Content: "Source line four.",
},
},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Content: "Target line two.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 2 {
t.Errorf("Expected 2 entries, got %d", len(result))
return
}
// Check that source timings are scaled appropriately
if result[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source timing, got: %+v", result[0].StartTime)
}
if result[1].StartTime.Seconds != 10 {
t.Errorf("Last entry should have last source timing, got: %+v", result[1].StartTime)
}
// Check that content is preserved
if result[0].Content != "Target line one." || result[1].Content != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result)
}
// Check that numbering is correct
if result[0].Number != 1 || result[1].Number != 2 {
t.Errorf("Entry numbers should be sequential: %+v", result)
}
},
},
{
name: "Empty target entries",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
},
targetEntries: []model.SRTEntry{},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 0 {
t.Errorf("Expected 0 entries, got %d", len(result))
}
},
},
{
name: "Empty source entries",
sourceEntries: []model.SRTEntry{},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Target line one.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 1 {
t.Errorf("Expected 1 entry, got %d", len(result))
return
}
// Check that numbering is correct even with empty source
if result[0].Number != 1 {
t.Errorf("Entry number should be 1, got: %d", result[0].Number)
}
// Content should be preserved
if result[0].Content != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result[0].Content)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncSRTTimeline(tc.sourceEntries, tc.targetEntries)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}

View file

@ -1,12 +1,9 @@
package sync package sync
import ( import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"sub-cli/internal/format/lrc"
"sub-cli/internal/model"
) )
// SyncLyrics synchronizes the timeline of a source lyrics file with a target lyrics file // SyncLyrics synchronizes the timeline of a source lyrics file with a target lyrics file
@ -14,66 +11,16 @@ func SyncLyrics(sourceFile, targetFile string) error {
sourceFmt := strings.TrimPrefix(filepath.Ext(sourceFile), ".") sourceFmt := strings.TrimPrefix(filepath.Ext(sourceFile), ".")
targetFmt := strings.TrimPrefix(filepath.Ext(targetFile), ".") targetFmt := strings.TrimPrefix(filepath.Ext(targetFile), ".")
// Currently only supports LRC files // Check for supported format combinations
if sourceFmt != "lrc" || targetFmt != "lrc" { if sourceFmt == "lrc" && targetFmt == "lrc" {
return fmt.Errorf("sync only supports LRC files currently") return syncLRCFiles(sourceFile, targetFile)
} else if sourceFmt == "srt" && targetFmt == "srt" {
return syncSRTFiles(sourceFile, targetFile)
} else if sourceFmt == "vtt" && targetFmt == "vtt" {
return syncVTTFiles(sourceFile, targetFile)
} else if sourceFmt == "ass" && targetFmt == "ass" {
return syncASSFiles(sourceFile, targetFile)
} else {
return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc, srt-to-srt, vtt-to-vtt, or ass-to-ass)")
} }
source, err := lrc.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source file: %w", err)
}
target, err := lrc.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target file: %w", err)
}
// Apply timeline from source to target
syncedLyrics := syncTimeline(source, target)
// Write the synced lyrics to the target file
return lrc.Generate(syncedLyrics, targetFile)
}
// syncTimeline applies the timeline from the source lyrics to the target lyrics
func syncTimeline(source, target model.Lyrics) model.Lyrics {
result := model.Lyrics{
Metadata: target.Metadata,
Content: target.Content,
}
// Use source timeline if available and lengths match
if len(source.Timeline) > 0 && len(source.Timeline) == len(target.Content) {
result.Timeline = source.Timeline
} else if len(source.Timeline) > 0 {
// If lengths don't match, scale timeline
result.Timeline = scaleTimeline(source.Timeline, len(target.Content))
}
return result
}
// scaleTimeline scales a timeline to match a different number of entries
func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp {
if targetCount <= 0 || len(timeline) == 0 {
return []model.Timestamp{}
}
result := make([]model.Timestamp, targetCount)
if targetCount == 1 {
result[0] = timeline[0]
return result
}
sourceLength := len(timeline)
for i := 0; i < targetCount; i++ {
// Scale index to match source timeline
sourceIndex := i * (sourceLength - 1) / (targetCount - 1)
result[i] = timeline[sourceIndex]
}
return result
} }

297
internal/sync/sync_test.go Normal file
View file

@ -0,0 +1,297 @@
package sync
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSyncLyrics(t *testing.T) {
// Create temporary test directory
tempDir := t.TempDir()
// Test cases for different format combinations
testCases := []struct {
name string
sourceContent string
sourceExt string
targetContent string
targetExt string
expectedError bool
validateOutput func(t *testing.T, filePath string)
}{
{
name: "LRC to LRC sync",
sourceContent: `[ti:Source LRC]
[ar:Test Artist]
[00:01.00]This is line one.
[00:05.00]This is line two.
[00:09.50]This is line three.
`,
sourceExt: "lrc",
targetContent: `[ti:Target LRC]
[ar:Different Artist]
[00:10.00]This is line one with different timing.
[00:20.00]This is line two with different timing.
[00:30.00]This is line three with different timing.
`,
targetExt: "lrc",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Should contain target title but source timings
if !strings.Contains(contentStr, "[ti:Target LRC]") {
t.Errorf("Output should preserve target title, got: %s", contentStr)
}
if !strings.Contains(contentStr, "[ar:Different Artist]") {
t.Errorf("Output should preserve target artist, got: %s", contentStr)
}
// Should have source timings
if !strings.Contains(contentStr, "[00:01.000]") {
t.Errorf("Output should have source timing [00:01.000], got: %s", contentStr)
}
// Should have target content
if !strings.Contains(contentStr, "This is line one with different timing.") {
t.Errorf("Output should preserve target content, got: %s", contentStr)
}
},
},
{
name: "SRT to SRT sync",
sourceContent: `1
00:00:01,000 --> 00:00:04,000
This is line one.
2
00:00:05,000 --> 00:00:08,000
This is line two.
3
00:00:09,000 --> 00:00:12,000
This is line three.
`,
sourceExt: "srt",
targetContent: `1
00:01:00,000 --> 00:01:03,000
This is target line one.
2
00:01:05,000 --> 00:01:08,000
This is target line two.
3
00:01:10,000 --> 00:01:13,000
This is target line three.
`,
targetExt: "srt",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Should have source timings but target content
if !strings.Contains(contentStr, "00:00:01,000 -->") {
t.Errorf("Output should have source timing 00:00:01,000, got: %s", contentStr)
}
// Check target content is preserved
if !strings.Contains(contentStr, "This is target line one.") {
t.Errorf("Output should preserve target content, got: %s", contentStr)
}
// Check identifiers are sequential
if !strings.Contains(contentStr, "1\n00:00:01,000") {
t.Errorf("Output should have sequential identifiers starting with 1, got: %s", contentStr)
}
},
},
{
name: "VTT to VTT sync",
sourceContent: `WEBVTT
1
00:00:01.000 --> 00:00:04.000
This is line one.
2
00:00:05.000 --> 00:00:08.000
This is line two.
3
00:00:09.000 --> 00:00:12.000
This is line three.
`,
sourceExt: "vtt",
targetContent: `WEBVTT - Target Title
1
00:01:00.000 --> 00:01:03.000 align:start position:10%
This is target line one.
2
00:01:05.000 --> 00:01:08.000 align:middle
This is target line two.
3
00:01:10.000 --> 00:01:13.000
This is target line three.
`,
targetExt: "vtt",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Should preserve VTT title
if !strings.Contains(contentStr, "WEBVTT - Target Title") {
t.Errorf("Output should preserve target title, got: %s", contentStr)
}
// Should have source timings but target content and settings
if !strings.Contains(contentStr, "00:00:01.000 -->") {
t.Errorf("Output should have source timing 00:00:01.000, got: %s", contentStr)
}
// Should preserve styling cue settings
if !strings.Contains(contentStr, "align:start position:10%") {
t.Errorf("Output should preserve cue settings, got: %s", contentStr)
}
// Check target content is preserved
if !strings.Contains(contentStr, "This is target line one.") {
t.Errorf("Output should preserve target content, got: %s", contentStr)
}
},
},
{
name: "ASS to ASS sync",
sourceContent: `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Timer: 100.0000
Title: Source ASS File
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,Source line one.
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,Source line two.
Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,Source line three.
`,
sourceExt: "ass",
targetContent: `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Timer: 100.0000
Title: Target ASS File
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Alternate,Arial,20,&H0000FFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:01:00.00,0:01:03.00,Default,,0,0,0,,Target line one.
Dialogue: 0,0:01:05.00,0:01:08.00,Alternate,,0,0,0,,Target line two.
Dialogue: 0,0:01:10.00,0:01:13.00,Default,,0,0,0,,Target line three.
`,
targetExt: "ass",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Should have source timings but target content
if !strings.Contains(contentStr, "0:00:01.00") {
t.Errorf("Output should have source timing 0:00:01.00, got: %s", contentStr)
}
// Check target content is preserved
if !strings.Contains(contentStr, "Target line one.") {
t.Errorf("Output should preserve target content, got: %s", contentStr)
}
// Check target styles are preserved
if !strings.Contains(contentStr, "Style: Alternate") {
t.Errorf("Output should preserve target styles, got: %s", contentStr)
}
// Check target title is preserved
if !strings.Contains(contentStr, "Title: Target ASS File") {
t.Errorf("Output should preserve target title, got: %s", contentStr)
}
},
},
{
name: "Unsupported format combination",
sourceContent: `[00:01.00]This is line one.`,
sourceExt: "lrc",
targetContent: `1\n00:00:01,000 --> 00:00:04,000\nThis is line one.`,
targetExt: "srt",
expectedError: true,
validateOutput: func(t *testing.T, filePath string) {
// Not needed for error case
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sourceFile := filepath.Join(tempDir, "source."+tc.sourceExt)
targetFile := filepath.Join(tempDir, "target."+tc.targetExt)
// Write test files
if err := os.WriteFile(sourceFile, []byte(tc.sourceContent), 0644); err != nil {
t.Fatalf("Failed to write source file: %v", err)
}
if err := os.WriteFile(targetFile, []byte(tc.targetContent), 0644); err != nil {
t.Fatalf("Failed to write target file: %v", err)
}
// Run SyncLyrics
err := SyncLyrics(sourceFile, targetFile)
// Check error status
if tc.expectedError && err == nil {
t.Errorf("Expected error but got nil")
} else if !tc.expectedError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
// If no error is expected, validate the output
if !tc.expectedError && err == nil {
tc.validateOutput(t, targetFile)
}
})
}
}

136
internal/sync/utils.go Normal file
View file

@ -0,0 +1,136 @@
package sync
import (
"sub-cli/internal/model"
)
// scaleTimeline scales a timeline to match a different number of entries
func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp {
if targetCount <= 0 || len(timeline) == 0 {
return []model.Timestamp{}
}
result := make([]model.Timestamp, targetCount)
if targetCount == 1 {
result[0] = timeline[0]
return result
}
sourceLength := len(timeline)
// Handle simple case: same length
if targetCount == sourceLength {
copy(result, timeline)
return result
}
// Handle case where target is longer than source
// We need to interpolate timestamps between source entries
for i := 0; i < targetCount; i++ {
if sourceLength == 1 {
// If source has only one entry, use it for all target entries
result[i] = timeline[0]
continue
}
// Calculate a floating-point position in the source timeline
floatIndex := float64(i) * float64(sourceLength-1) / float64(targetCount-1)
lowerIndex := int(floatIndex)
upperIndex := lowerIndex + 1
// Handle boundary case
if upperIndex >= sourceLength {
upperIndex = sourceLength - 1
lowerIndex = upperIndex - 1
}
// If indices are the same, just use the source timestamp
if lowerIndex == upperIndex || lowerIndex < 0 {
result[i] = timeline[upperIndex]
} else {
// Calculate the fraction between the lower and upper indices
fraction := floatIndex - float64(lowerIndex)
// Convert timestamps to milliseconds for interpolation
lowerMS := timeline[lowerIndex].Hours*3600000 + timeline[lowerIndex].Minutes*60000 +
timeline[lowerIndex].Seconds*1000 + timeline[lowerIndex].Milliseconds
upperMS := timeline[upperIndex].Hours*3600000 + timeline[upperIndex].Minutes*60000 +
timeline[upperIndex].Seconds*1000 + timeline[upperIndex].Milliseconds
// Interpolate
resultMS := int(float64(lowerMS) + fraction*float64(upperMS-lowerMS))
// Convert back to timestamp
hours := resultMS / 3600000
resultMS %= 3600000
minutes := resultMS / 60000
resultMS %= 60000
seconds := resultMS / 1000
milliseconds := resultMS % 1000
result[i] = model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
}
return result
}
// calculateDuration calculates the time difference between two timestamps
func calculateDuration(start, end model.Timestamp) model.Timestamp {
startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds
endMillis := end.Hours*3600000 + end.Minutes*60000 + end.Seconds*1000 + end.Milliseconds
durationMillis := endMillis - startMillis
if durationMillis < 0 {
// Return zero duration if end is before start
return model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 0,
Milliseconds: 0,
}
}
hours := durationMillis / 3600000
durationMillis %= 3600000
minutes := durationMillis / 60000
durationMillis %= 60000
seconds := durationMillis / 1000
milliseconds := durationMillis % 1000
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
// addDuration adds a duration to a timestamp
func addDuration(start, duration model.Timestamp) model.Timestamp {
startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds
durationMillis := duration.Hours*3600000 + duration.Minutes*60000 + duration.Seconds*1000 + duration.Milliseconds
totalMillis := startMillis + durationMillis
hours := totalMillis / 3600000
totalMillis %= 3600000
minutes := totalMillis / 60000
totalMillis %= 60000
seconds := totalMillis / 1000
milliseconds := totalMillis % 1000
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}

236
internal/sync/utils_test.go Normal file
View file

@ -0,0 +1,236 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestCalculateDuration(t *testing.T) {
testCases := []struct {
name string
start model.Timestamp
end model.Timestamp
expected model.Timestamp
}{
{
name: "Simple duration",
start: model.Timestamp{Minutes: 1, Seconds: 30},
end: model.Timestamp{Minutes: 3, Seconds: 10},
expected: model.Timestamp{Minutes: 1, Seconds: 40},
},
{
name: "Duration with hours",
start: model.Timestamp{Hours: 1, Minutes: 20},
end: model.Timestamp{Hours: 2, Minutes: 10},
expected: model.Timestamp{Hours: 0, Minutes: 50},
},
{
name: "Duration with milliseconds",
start: model.Timestamp{Seconds: 10, Milliseconds: 500},
end: model.Timestamp{Seconds: 20, Milliseconds: 800},
expected: model.Timestamp{Seconds: 10, Milliseconds: 300},
},
{
name: "End before start (should return zero)",
start: model.Timestamp{Minutes: 5},
end: model.Timestamp{Minutes: 3},
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0},
},
{
name: "Complex duration with carry",
start: model.Timestamp{Hours: 1, Minutes: 45, Seconds: 30, Milliseconds: 500},
end: model.Timestamp{Hours: 3, Minutes: 20, Seconds: 15, Milliseconds: 800},
expected: model.Timestamp{Hours: 1, Minutes: 34, Seconds: 45, Milliseconds: 300},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := calculateDuration(tc.start, tc.end)
if result.Hours != tc.expected.Hours ||
result.Minutes != tc.expected.Minutes ||
result.Seconds != tc.expected.Seconds ||
result.Milliseconds != tc.expected.Milliseconds {
t.Errorf("Expected %+v, got %+v", tc.expected, result)
}
})
}
}
func TestAddDuration(t *testing.T) {
testCases := []struct {
name string
start model.Timestamp
duration model.Timestamp
expected model.Timestamp
}{
{
name: "Simple addition",
start: model.Timestamp{Minutes: 1, Seconds: 30},
duration: model.Timestamp{Minutes: 2, Seconds: 15},
expected: model.Timestamp{Minutes: 3, Seconds: 45},
},
{
name: "Addition with carry",
start: model.Timestamp{Minutes: 58, Seconds: 45},
duration: model.Timestamp{Minutes: 4, Seconds: 30},
expected: model.Timestamp{Hours: 1, Minutes: 3, Seconds: 15},
},
{
name: "Addition with milliseconds",
start: model.Timestamp{Seconds: 10, Milliseconds: 500},
duration: model.Timestamp{Seconds: 5, Milliseconds: 800},
expected: model.Timestamp{Seconds: 16, Milliseconds: 300},
},
{
name: "Zero duration",
start: model.Timestamp{Minutes: 5, Seconds: 30},
duration: model.Timestamp{},
expected: model.Timestamp{Minutes: 5, Seconds: 30},
},
{
name: "Complex addition with multiple carries",
start: model.Timestamp{Hours: 1, Minutes: 59, Seconds: 59, Milliseconds: 900},
duration: model.Timestamp{Hours: 2, Minutes: 10, Seconds: 15, Milliseconds: 200},
expected: model.Timestamp{Hours: 4, Minutes: 10, Seconds: 15, Milliseconds: 100},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := addDuration(tc.start, tc.duration)
if result.Hours != tc.expected.Hours ||
result.Minutes != tc.expected.Minutes ||
result.Seconds != tc.expected.Seconds ||
result.Milliseconds != tc.expected.Milliseconds {
t.Errorf("Expected %+v, got %+v", tc.expected, result)
}
})
}
}
func TestScaleTimeline(t *testing.T) {
testCases := []struct {
name string
timeline []model.Timestamp
targetCount int
expected []model.Timestamp
}{
{
name: "Same length timeline",
timeline: []model.Timestamp{
{Seconds: 1},
{Seconds: 2},
{Seconds: 3},
},
targetCount: 3,
expected: []model.Timestamp{
{Seconds: 1},
{Seconds: 2},
{Seconds: 3},
},
},
{
name: "Empty timeline",
timeline: []model.Timestamp{},
targetCount: 3,
expected: []model.Timestamp{},
},
{
name: "Zero target count",
timeline: []model.Timestamp{
{Seconds: 1},
{Seconds: 2},
},
targetCount: 0,
expected: []model.Timestamp{},
},
{
name: "Single item timeline",
timeline: []model.Timestamp{
{Seconds: 5},
},
targetCount: 3,
expected: []model.Timestamp{
{Seconds: 5},
{Seconds: 5},
{Seconds: 5},
},
},
{
name: "Scale up timeline",
timeline: []model.Timestamp{
{Seconds: 0},
{Seconds: 10},
},
targetCount: 5,
expected: []model.Timestamp{
{Seconds: 0},
{Seconds: 2, Milliseconds: 500},
{Seconds: 5},
{Seconds: 7, Milliseconds: 500},
{Seconds: 10},
},
},
{
name: "Scale down timeline",
timeline: []model.Timestamp{
{Seconds: 0},
{Seconds: 5},
{Seconds: 10},
{Seconds: 15},
{Seconds: 20},
},
targetCount: 3,
expected: []model.Timestamp{
{Seconds: 0},
{Seconds: 10},
{Seconds: 20},
},
},
{
name: "Target count 1",
timeline: []model.Timestamp{
{Seconds: 5},
{Seconds: 10},
{Seconds: 15},
},
targetCount: 1,
expected: []model.Timestamp{
{Seconds: 5},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := scaleTimeline(tc.timeline, tc.targetCount)
if len(result) != len(tc.expected) {
t.Errorf("Expected result length %d, got %d", len(tc.expected), len(result))
return
}
for i := range result {
// Allow 1ms difference due to floating point calculations
if abs(result[i].Hours - tc.expected[i].Hours) > 0 ||
abs(result[i].Minutes - tc.expected[i].Minutes) > 0 ||
abs(result[i].Seconds - tc.expected[i].Seconds) > 0 ||
abs(result[i].Milliseconds - tc.expected[i].Milliseconds) > 1 {
t.Errorf("At index %d: expected %+v, got %+v", i, tc.expected[i], result[i])
}
}
})
}
}
// Helper function for timestamp comparison
func abs(x int) int {
if x < 0 {
return -x
}
return x
}

104
internal/sync/vtt.go Normal file
View file

@ -0,0 +1,104 @@
package sync
import (
"fmt"
"sub-cli/internal/format/vtt"
"sub-cli/internal/model"
)
// syncVTTFiles synchronizes two VTT files
func syncVTTFiles(sourceFile, targetFile string) error {
sourceSubtitle, err := vtt.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source VTT file: %w", err)
}
targetSubtitle, err := vtt.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target VTT file: %w", err)
}
// Check if entry counts match
if len(sourceSubtitle.Entries) != len(targetSubtitle.Entries) {
fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n",
len(sourceSubtitle.Entries), len(targetSubtitle.Entries))
}
// Sync the timelines
syncedSubtitle := syncVTTTimeline(sourceSubtitle, targetSubtitle)
// Write the synced subtitle to the target file
return vtt.Generate(syncedSubtitle, targetFile)
}
// syncVTTTimeline applies the timing from source VTT subtitle to target VTT subtitle
func syncVTTTimeline(source, target model.Subtitle) model.Subtitle {
result := model.NewSubtitle()
result.Format = "vtt"
result.Title = target.Title
result.Metadata = target.Metadata
result.Styles = target.Styles
// Create entries array with same length as target
result.Entries = make([]model.SubtitleEntry, len(target.Entries))
// Copy target entries
copy(result.Entries, target.Entries)
// If source subtitle is empty or target subtitle is empty, return copied target
if len(source.Entries) == 0 || len(target.Entries) == 0 {
// Ensure proper index numbering
for i := range result.Entries {
result.Entries[i].Index = i + 1
}
return result
}
// If source and target have the same number of entries, directly apply timings
if len(source.Entries) == len(target.Entries) {
for i := range result.Entries {
result.Entries[i].StartTime = source.Entries[i].StartTime
result.Entries[i].EndTime = source.Entries[i].EndTime
}
} else {
// If entry counts differ, scale the timing similar to SRT sync
for i := range result.Entries {
// Calculate scaled index
sourceIdx := 0
if len(source.Entries) > 1 {
sourceIdx = i * (len(source.Entries) - 1) / (len(target.Entries) - 1)
}
// Ensure the index is within bounds
if sourceIdx >= len(source.Entries) {
sourceIdx = len(source.Entries) - 1
}
// Apply the scaled timing
result.Entries[i].StartTime = source.Entries[sourceIdx].StartTime
// Calculate end time: if not the last entry, use duration from source
if i < len(result.Entries)-1 {
// If next source entry exists, calculate duration
var duration model.Timestamp
if sourceIdx+1 < len(source.Entries) {
duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx+1].StartTime)
} else {
duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx].EndTime)
}
result.Entries[i].EndTime = addDuration(result.Entries[i].StartTime, duration)
} else {
// For the last entry, use the end time from source
result.Entries[i].EndTime = source.Entries[sourceIdx].EndTime
}
}
}
// Ensure proper index numbering
for i := range result.Entries {
result.Entries[i].Index = i + 1
}
return result
}

342
internal/sync/vtt_test.go Normal file
View file

@ -0,0 +1,342 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestSyncVTTTimeline(t *testing.T) {
testCases := []struct {
name string
source model.Subtitle
target model.Subtitle
verify func(t *testing.T, result model.Subtitle)
}{
{
name: "Equal entry counts",
source: model.Subtitle{
Format: "vtt",
Title: "Source VTT",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "Source line two.",
},
{
Index: 3,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Text: "Source line three.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Title: "Target VTT",
Styles: map[string]string{
"style1": ".style1 { color: red; }",
},
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
Styles: map[string]string{
"align": "start",
"position": "10%",
},
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
Styles: map[string]string{
"align": "middle",
},
},
{
Index: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result.Entries))
return
}
// Check that source timings are applied to target entries
if result.Entries[0].StartTime.Seconds != 1 || result.Entries[0].EndTime.Seconds != 4 {
t.Errorf("First entry timing mismatch: got %+v", result.Entries[0])
}
if result.Entries[1].StartTime.Seconds != 5 || result.Entries[1].EndTime.Seconds != 8 {
t.Errorf("Second entry timing mismatch: got %+v", result.Entries[1])
}
if result.Entries[2].StartTime.Seconds != 9 || result.Entries[2].EndTime.Seconds != 12 {
t.Errorf("Third entry timing mismatch: got %+v", result.Entries[2])
}
// Check that target content is preserved
if result.Entries[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Entries[0].Text)
}
// Check that styles are preserved
if result.Entries[0].Styles["align"] != "start" || result.Entries[0].Styles["position"] != "10%" {
t.Errorf("Styles should be preserved, got: %+v", result.Entries[0].Styles)
}
// Check that global styles are preserved
if result.Styles["style1"] != ".style1 { color: red; }" {
t.Errorf("Global styles should be preserved, got: %+v", result.Styles)
}
// Check that numbering is correct
if result.Entries[0].Index != 1 || result.Entries[1].Index != 2 || result.Entries[2].Index != 3 {
t.Errorf("Entry indices should be sequential: %+v", result.Entries)
}
},
},
{
name: "More target entries than source",
source: model.Subtitle{
Format: "vtt",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "Source line two.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Title: "Target VTT",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
},
{
Index: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result.Entries))
return
}
// First entry should use first source timing
if result.Entries[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source timing, got: %+v", result.Entries[0].StartTime)
}
// Last entry should use last source timing
if result.Entries[2].StartTime.Seconds != 5 {
t.Errorf("Last entry should have last source timing, got: %+v", result.Entries[2].StartTime)
}
// Check that target content is preserved
if result.Entries[2].Text != "Target line three." {
t.Errorf("Content should be preserved, got: %s", result.Entries[2].Text)
}
// Check that title is preserved
if result.Title != "Target VTT" {
t.Errorf("Title should be preserved, got: %s", result.Title)
}
},
},
{
name: "More source entries than target",
source: model.Subtitle{
Format: "vtt",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 3},
Text: "Source line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Seconds: 4},
EndTime: model.Timestamp{Seconds: 6},
Text: "Source line two.",
},
{
Index: 3,
StartTime: model.Timestamp{Seconds: 7},
EndTime: model.Timestamp{Seconds: 9},
Text: "Source line three.",
},
{
Index: 4,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 12},
Text: "Source line four.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Metadata: map[string]string{
"Region": "metadata region",
},
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(result.Entries))
return
}
// First entry should have first source timing
if result.Entries[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source timing, got: %+v", result.Entries[0].StartTime)
}
// Last entry should have last source timing
if result.Entries[1].StartTime.Seconds != 10 {
t.Errorf("Last entry should have last source timing, got: %+v", result.Entries[1].StartTime)
}
// Check that metadata is preserved
if result.Metadata["Region"] != "metadata region" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
// Check that target content is preserved
if result.Entries[0].Text != "Target line one." || result.Entries[1].Text != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result.Entries)
}
},
},
{
name: "Empty target entries",
source: model.Subtitle{
Format: "vtt",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Title: "Empty Target",
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 0 {
t.Errorf("Expected 0 entries, got %d", len(result.Entries))
}
// Title should be preserved
if result.Title != "Empty Target" {
t.Errorf("Title should be preserved, got: %s", result.Title)
}
},
},
{
name: "Empty source entries",
source: model.Subtitle{
Format: "vtt",
},
target: model.Subtitle{
Format: "vtt",
Title: "Target with content",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 15},
Text: "Target line one.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 1 {
t.Errorf("Expected 1 entry, got %d", len(result.Entries))
return
}
// Timing should be preserved since source is empty
if result.Entries[0].StartTime.Seconds != 10 || result.Entries[0].EndTime.Seconds != 15 {
t.Errorf("Timing should match target when source is empty, got: %+v", result.Entries[0])
}
// Content should be preserved
if result.Entries[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Entries[0].Text)
}
// Title should be preserved
if result.Title != "Target with content" {
t.Errorf("Title should be preserved, got: %s", result.Title)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncVTTTimeline(tc.source, tc.target)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}

15
internal/testdata/test.ass vendored Normal file
View file

@ -0,0 +1,15 @@
[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Title: ASS Test File
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,First line
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,Second line
Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,Third line

9
internal/testdata/test.lrc vendored Normal file
View file

@ -0,0 +1,9 @@
[ti:Test LRC File]
[ar:Test Artist]
[al:Test Album]
[by:Test Creator]
[00:01.00]This is the first subtitle line.
[00:05.00]This is the second subtitle line.
[00:09.50]This is the third subtitle line
[00:12.80]with a line break.

12
internal/testdata/test.srt vendored Normal file
View file

@ -0,0 +1,12 @@
1
00:00:01,000 --> 00:00:04,000
This is the first subtitle line.
2
00:00:05,000 --> 00:00:08,000
This is the second subtitle line.
3
00:00:09,500 --> 00:00:12,800
This is the third subtitle line
with a line break.

14
internal/testdata/test.vtt vendored Normal file
View file

@ -0,0 +1,14 @@
WEBVTT
1
00:00:01.000 --> 00:00:04.000
This is the first subtitle line.
2
00:00:05.000 --> 00:00:08.000
This is the second subtitle line.
3
00:00:09.500 --> 00:00:12.800
This is the third subtitle line
with a line break.

342
tests/integration_test.go Normal file
View file

@ -0,0 +1,342 @@
package tests
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestIntegration_EndToEnd runs a series of commands to test the entire workflow
func TestIntegration_EndToEnd(t *testing.T) {
// Skip if not running integration tests
if os.Getenv("RUN_INTEGRATION_TESTS") == "" {
t.Skip("Skipping integration tests. Set RUN_INTEGRATION_TESTS=1 to run.")
}
// Get the path to the built binary
binaryPath := os.Getenv("BINARY_PATH")
if binaryPath == "" {
// Default to looking in the current directory
binaryPath = "sub-cli"
}
// Create temporary directory for test files
tempDir := t.TempDir()
// Test files
srtFile := filepath.Join(tempDir, "test.srt")
lrcFile := filepath.Join(tempDir, "test.lrc")
vttFile := filepath.Join(tempDir, "test.vtt")
txtFile := filepath.Join(tempDir, "test.txt")
// Create SRT test file
srtContent := `1
00:00:01,000 --> 00:00:04,000
This is the first subtitle line.
2
00:00:05,000 --> 00:00:08,000
This is the second subtitle line.
3
00:00:09,500 --> 00:00:12,800
This is the third subtitle line
with a line break.
`
if err := os.WriteFile(srtFile, []byte(srtContent), 0644); err != nil {
t.Fatalf("Failed to create SRT test file: %v", err)
}
// Step 1: Test conversion from SRT to LRC
t.Log("Testing SRT to LRC conversion...")
cmd := exec.Command(binaryPath, "convert", srtFile, lrcFile)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Convert command failed: %v\nOutput: %s", err, output)
}
// Verify LRC file was created
if _, err := os.Stat(lrcFile); os.IsNotExist(err) {
t.Fatalf("LRC file was not created")
}
// Read LRC content
lrcContent, err := os.ReadFile(lrcFile)
if err != nil {
t.Fatalf("Failed to read LRC file: %v", err)
}
// Verify LRC content
if !strings.Contains(string(lrcContent), "[00:01.000]") {
t.Errorf("Expected LRC to contain timeline [00:01.000], got: %s", string(lrcContent))
}
if !strings.Contains(string(lrcContent), "This is the first subtitle line.") {
t.Errorf("Expected LRC to contain text content, got: %s", string(lrcContent))
}
// Step 2: Create a new SRT file with different timing
srtModifiedContent := `1
00:00:10,000 --> 00:00:14,000
This is the first subtitle line.
2
00:00:15,000 --> 00:00:18,000
This is the second subtitle line.
3
00:00:19,500 --> 00:00:22,800
This is the third subtitle line
with a line break.
`
srtModifiedFile := filepath.Join(tempDir, "modified.srt")
if err := os.WriteFile(srtModifiedFile, []byte(srtModifiedContent), 0644); err != nil {
t.Fatalf("Failed to create modified SRT test file: %v", err)
}
// Step 3: Test sync between SRT files
t.Log("Testing SRT to SRT sync...")
cmd = exec.Command(binaryPath, "sync", srtModifiedFile, srtFile)
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("Sync command failed: %v\nOutput: %s", err, output)
}
// Read synced SRT content
syncedSrtContent, err := os.ReadFile(srtFile)
if err != nil {
t.Fatalf("Failed to read synced SRT file: %v", err)
}
// Verify synced content has new timings but original text
if !strings.Contains(string(syncedSrtContent), "00:00:10,000 -->") {
t.Errorf("Expected synced SRT to have new timing 00:00:10,000, got: %s", string(syncedSrtContent))
}
if !strings.Contains(string(syncedSrtContent), "This is the first subtitle line.") {
t.Errorf("Expected synced SRT to preserve original text, got: %s", string(syncedSrtContent))
}
// Step 4: Test conversion from SRT to VTT
t.Log("Testing SRT to VTT conversion...")
cmd = exec.Command(binaryPath, "convert", srtFile, vttFile)
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("Convert command failed: %v\nOutput: %s", err, output)
}
// Verify VTT file was created
if _, err := os.Stat(vttFile); os.IsNotExist(err) {
t.Fatalf("VTT file was not created")
}
// Read VTT content
vttContent, err := os.ReadFile(vttFile)
if err != nil {
t.Fatalf("Failed to read VTT file: %v", err)
}
// Verify VTT content
if !strings.Contains(string(vttContent), "WEBVTT") {
t.Errorf("Expected VTT to contain WEBVTT header, got: %s", string(vttContent))
}
if !strings.Contains(string(vttContent), "00:00:10.000 -->") {
t.Errorf("Expected VTT to contain timeline 00:00:10.000, got: %s", string(vttContent))
}
// Step 5: Create VTT file with styling
vttStyledContent := `WEBVTT - Styled Test
STYLE
::cue {
color: white;
background-color: black;
}
1
00:00:20.000 --> 00:00:24.000 align:start position:10%
This is a <b>styled</b> subtitle.
2
00:00:25.000 --> 00:00:28.000 align:middle
This is another styled subtitle.
`
vttStyledFile := filepath.Join(tempDir, "styled.vtt")
if err := os.WriteFile(vttStyledFile, []byte(vttStyledContent), 0644); err != nil {
t.Fatalf("Failed to create styled VTT test file: %v", err)
}
// Step 6: Test sync between VTT files (should preserve styling)
t.Log("Testing VTT to VTT sync...")
cmd = exec.Command(binaryPath, "sync", vttFile, vttStyledFile)
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("Sync command failed: %v\nOutput: %s", err, output)
}
// Read synced VTT content
syncedVttContent, err := os.ReadFile(vttStyledFile)
if err != nil {
t.Fatalf("Failed to read synced VTT file: %v", err)
}
// Verify synced content has new timings but preserves styling and text
if !strings.Contains(string(syncedVttContent), "00:00:10.000 -->") {
t.Errorf("Expected synced VTT to have new timing 00:00:10.000, got: %s", string(syncedVttContent))
}
if !strings.Contains(string(syncedVttContent), "align:") {
t.Errorf("Expected synced VTT to preserve styling, got: %s", string(syncedVttContent))
}
if !strings.Contains(string(syncedVttContent), "<b>styled</b>") {
t.Errorf("Expected synced VTT to preserve HTML formatting, got: %s", string(syncedVttContent))
}
// Step 7: Test format command with VTT file
t.Log("Testing VTT formatting...")
cmd = exec.Command(binaryPath, "fmt", vttStyledFile)
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("Format command failed: %v\nOutput: %s", err, output)
}
// Read formatted VTT content
formattedVttContent, err := os.ReadFile(vttStyledFile)
if err != nil {
t.Fatalf("Failed to read formatted VTT file: %v", err)
}
// Verify formatted content preserves styling and has sequential cue identifiers
if !strings.Contains(string(formattedVttContent), "1\n00:00:10.000") {
t.Errorf("Expected formatted VTT to have sequential identifiers, got: %s", string(formattedVttContent))
}
if !strings.Contains(string(formattedVttContent), "align:") {
t.Errorf("Expected formatted VTT to preserve styling, got: %s", string(formattedVttContent))
}
// Step 8: Test conversion to plain text
t.Log("Testing VTT to TXT conversion...")
cmd = exec.Command(binaryPath, "convert", vttStyledFile, txtFile)
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("Convert command failed: %v\nOutput: %s", err, output)
}
// Verify TXT file was created
if _, err := os.Stat(txtFile); os.IsNotExist(err) {
t.Fatalf("TXT file was not created")
}
// Read TXT content
txtContent, err := os.ReadFile(txtFile)
if err != nil {
t.Fatalf("Failed to read TXT file: %v", err)
}
// Verify TXT content has text but no timing or styling
if strings.Contains(string(txtContent), "00:00:") {
t.Errorf("Expected TXT to not contain timing information, got: %s", string(txtContent))
}
if strings.Contains(string(txtContent), "align:") {
t.Errorf("Expected TXT to not contain styling information, got: %s", string(txtContent))
}
if !strings.Contains(string(txtContent), "styled") {
t.Errorf("Expected TXT to contain text content, got: %s", string(txtContent))
}
t.Log("All integration tests passed!")
}
// TestIntegration_ErrorHandling tests how the CLI handles error conditions
func TestIntegration_ErrorHandling(t *testing.T) {
// Skip if not running integration tests
if os.Getenv("RUN_INTEGRATION_TESTS") == "" {
t.Skip("Skipping integration tests. Set RUN_INTEGRATION_TESTS=1 to run.")
}
// Get the path to the built binary
binaryPath := os.Getenv("BINARY_PATH")
if binaryPath == "" {
// Default to looking in the current directory
binaryPath = "sub-cli"
}
// Create temporary directory for test files
tempDir := t.TempDir()
// Test cases
testCases := []struct {
name string
args []string
errorMsg string
}{
{
name: "Nonexistent source file",
args: []string{"convert", "nonexistent.srt", filepath.Join(tempDir, "output.vtt")},
errorMsg: "no such file",
},
{
name: "Invalid source format",
args: []string{"convert", filepath.Join(tempDir, "test.xyz"), filepath.Join(tempDir, "output.vtt")},
errorMsg: "unsupported format",
},
{
name: "Invalid target format",
args: []string{"convert", filepath.Join(tempDir, "test.srt"), filepath.Join(tempDir, "output.xyz")},
errorMsg: "unsupported format",
},
{
name: "Sync different formats",
args: []string{"sync", filepath.Join(tempDir, "test.srt"), filepath.Join(tempDir, "test.lrc")},
errorMsg: "same format",
},
{
name: "Format unsupported file",
args: []string{"fmt", filepath.Join(tempDir, "test.txt")},
errorMsg: "unsupported format",
},
}
// Create a sample SRT file for testing
srtFile := filepath.Join(tempDir, "test.srt")
srtContent := `1
00:00:01,000 --> 00:00:04,000
This is a test subtitle.
`
if err := os.WriteFile(srtFile, []byte(srtContent), 0644); err != nil {
t.Fatalf("Failed to create SRT test file: %v", err)
}
// Create a sample LRC file for testing
lrcFile := filepath.Join(tempDir, "test.lrc")
lrcContent := `[00:01.00]This is a test lyric.
`
if err := os.WriteFile(lrcFile, []byte(lrcContent), 0644); err != nil {
t.Fatalf("Failed to create LRC test file: %v", err)
}
// Create a sample TXT file for testing
txtFile := filepath.Join(tempDir, "test.txt")
txtContent := `This is a plain text file.
`
if err := os.WriteFile(txtFile, []byte(txtContent), 0644); err != nil {
t.Fatalf("Failed to create TXT test file: %v", err)
}
// Run test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command(binaryPath, tc.args...)
output, err := cmd.CombinedOutput()
// We expect an error
if err == nil {
t.Fatalf("Expected command to fail, but it succeeded. Output: %s", output)
}
// Check error message
if !strings.Contains(string(output), tc.errorMsg) {
t.Errorf("Expected error message to contain '%s', got: %s", tc.errorMsg, output)
}
})
}
}