diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8bdf91..0cd6265 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,11 +41,18 @@ jobs: CGO_ENABLED: '0' run: | VERSION="${GITHUB_REF_NAME#v}" - go generate ./... # sync backend + PodStack commands for go:embed + go generate ./... # sync backend + PodStack commands for go:embed, write VERSION + # The launcher embeds VERSION (generated from package.json). Refuse to ship a + # tag that disagrees with it rather than publish a mislabelled binary. + EMBEDDED="$(cat VERSION)" + if [ "$VERSION" != "$EMBEDDED" ]; then + echo "tag v${VERSION} does not match package.json version ${EMBEDDED}" >&2 + exit 1 + fi EXT="" [ "${{ matrix.goos }}" = "windows" ] && EXT=".exe" OUT="podcli-${{ matrix.goos }}-${{ matrix.goarch }}${EXT}" - go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" -o "../${OUT}" . + go build -trimpath -ldflags "-s -w" -o "../${OUT}" . echo "ASSET=${OUT}" >> "$GITHUB_ENV" - uses: actions/upload-artifact@v4 with: diff --git a/RELEASE.md b/RELEASE.md index edfed04..90f559a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -15,10 +15,16 @@ unscoped name `podcli` is blocked by npm as too similar to `pod-cli`.) ## Cutting a release -1. Pick the version `X.Y.Z` and tag with it. The installers resolve the latest - release automatically. +1. Bump the version in `package.json`. It is the single source of truth: the + launcher embeds `cli/VERSION`, which `go generate` writes from `package.json`, + and the Python backend reads it too. + ```bash + cd cli && go generate ./... && git add VERSION # refresh the embedded version + ``` + A tag that disagrees with `package.json` fails the release build, and + `go test ./...` in `cli/` fails if `VERSION` is stale. 2. Merge to `main` and make sure CI is green. -3. Tag and push: +3. Tag and push. The tag must match `package.json`: ```bash git tag vX.Y.Z git push origin vX.Y.Z diff --git a/backend/cli.py b/backend/cli.py index a7b27df..79dea8f 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -1999,9 +1999,8 @@ def _find_sentence_boundary_end(segs, idx, max_lookahead=3): break if not title: title = text[:60].strip() - if len(title) > 55: - # Cut at word boundary - title = title[:55].rsplit(" ", 1)[0] + "..." + from utils.text import truncate_title + title = truncate_title(title) if score >= 5: # Higher threshold = better clips clips.append({ diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index 96d8840..5c98890 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -19,6 +19,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from presets import MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX +from utils.text import clean_title def _cli_name_exts() -> list[str]: @@ -844,7 +845,7 @@ def find_moments_from_text( continue found.append({ - "title": c.get("title", "Untitled")[:55], + "title": clean_title(c.get("title", "Untitled")), "start_second": keep_segments[0]["start"] if keep_segments else start_sec, "end_second": keep_segments[-1]["end"] if keep_segments else end_sec, "segments": keep_segments, @@ -1043,7 +1044,7 @@ def _parse_seconds(val) -> float: continue normalized.append({ - "title": c.get("title", "Untitled")[:55], + "title": clean_title(c.get("title", "Untitled")), "start_second": keep_segments[0]["start"] if keep_segments else start_sec, "end_second": keep_segments[-1]["end"] if keep_segments else end_sec, "segments": keep_segments, diff --git a/backend/utils/text.py b/backend/utils/text.py new file mode 100644 index 0000000..07d2d35 --- /dev/null +++ b/backend/utils/text.py @@ -0,0 +1,30 @@ +"""Title helpers shared by the suggestion pipeline and the CLI.""" + +TITLE_MAX = 55 + +_TRAILING_PUNCT = " ,;:-–—" + + +def clean_title(text: str) -> str: + """Normalize whitespace, keeping the title intact. + + Stored titles are never shortened: the UI wraps them and the terminal + truncates them, so a cap here would lose text that both could have shown. + """ + return " ".join((text or "").split()) + + +def truncate_title(text: str, limit: int = TITLE_MAX) -> str: + """Trim to limit characters on a word boundary, for fixed-width output. + + A raw slice ends titles mid-word ("...on an automot"), which reads as a + rendering fault rather than an abbreviation. A single long word still gets a + hard cut, since there is no boundary to fall back to. + """ + collapsed = clean_title(text) + if len(collapsed) <= limit: + return collapsed + head = collapsed[:limit] + if " " in head: + head = head.rsplit(" ", 1)[0] + return head.rstrip(_TRAILING_PUNCT) + "…" diff --git a/cli/VERSION b/cli/VERSION new file mode 100644 index 0000000..005119b --- /dev/null +++ b/cli/VERSION @@ -0,0 +1 @@ +2.4.1 diff --git a/cli/gen-version.sh b/cli/gen-version.sh new file mode 100755 index 0000000..32740b6 --- /dev/null +++ b/cli/gen-version.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Write VERSION from package.json, the single source of truth for the version. +# Run via `go generate ./...` before building. VERSION is this script's only writer. +set -e +here="$(cd "$(dirname "$0")" && pwd)" +ver=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$here/../package.json" | head -1) +[ -n "$ver" ] || { echo "gen-version: no \"version\" in package.json" >&2; exit 1; } +printf '%s\n' "$ver" > "$here/VERSION" +echo "version $ver -> $here/VERSION" diff --git a/cli/internal/backend/embed.go b/cli/internal/backend/embed.go index be0a363..c2af130 100644 --- a/cli/internal/backend/embed.go +++ b/cli/internal/backend/embed.go @@ -9,19 +9,42 @@ import ( "io/fs" "os" "path/filepath" + "strings" ) //go:generate sh sync.sh //go:embed all:files var files embed.FS -// Extract replaces dest with the embedded backend tree. dest is removed first so -// a stale dev symlink or an older extracted copy never shadows the shipped one. -func Extract(dest string) error { +// stampName matches the marker provision writes for the studio and Remotion +// bundles, so every version-bound artifact under runtime/ is checked the same way. +const stampName = ".podcli-version" + +// Version returns the launcher version that extracted dest, or "" if unstamped. +func Version(dest string) string { + b, err := os.ReadFile(filepath.Join(dest, stampName)) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// IsCurrent reports whether dest holds this launcher's backend. An unstamped dest +// is stale by definition: it predates stamping, so it carries whatever release +// first provisioned it. +func IsCurrent(dest, version string) bool { + return Version(dest) == version +} + +// Extract replaces dest with the embedded backend tree. dest is removed first so a +// stale dev symlink or an older extracted copy never shadows the shipped one. The +// stamp is written last, so a crash mid-extract leaves dest unstamped and the next +// run re-extracts. +func Extract(dest, version string) error { if err := os.RemoveAll(dest); err != nil { return err } - return fs.WalkDir(files, "files", func(p string, d fs.DirEntry, err error) error { + err := fs.WalkDir(files, "files", func(p string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -45,4 +68,8 @@ func Extract(dest string) error { } return os.WriteFile(target, data, 0o644) }) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dest, stampName), []byte(version), 0o644) } diff --git a/cli/internal/backend/embed_test.go b/cli/internal/backend/embed_test.go new file mode 100644 index 0000000..a1c0ca0 --- /dev/null +++ b/cli/internal/backend/embed_test.go @@ -0,0 +1,68 @@ +package backend + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExtractStampsVersion(t *testing.T) { + dest := filepath.Join(t.TempDir(), "backend") + if err := Extract(dest, "2.4.1"); err != nil { + t.Fatalf("Extract: %v", err) + } + if _, err := os.Stat(filepath.Join(dest, "cli.py")); err != nil { + t.Fatalf("cli.py missing after extract: %v", err) + } + if got := Version(dest); got != "2.4.1" { + t.Fatalf("Version = %q, want 2.4.1", got) + } + if !IsCurrent(dest, "2.4.1") { + t.Fatal("IsCurrent false right after extracting that version") + } +} + +// A backend provisioned before stamping existed carries no marker. Treating it as +// current is the bug that let a v2.2.1 backend run under a v2.4.0 launcher. +func TestUnstampedBackendIsStale(t *testing.T) { + dest := t.TempDir() + if err := os.WriteFile(filepath.Join(dest, "cli.py"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if Version(dest) != "" { + t.Fatal("unstamped dir reported a version") + } + if IsCurrent(dest, "2.4.1") { + t.Fatal("unstamped backend reported current") + } +} + +func TestStaleStampIsNotCurrent(t *testing.T) { + dest := filepath.Join(t.TempDir(), "backend") + if err := Extract(dest, "2.2.1"); err != nil { + t.Fatalf("Extract: %v", err) + } + if IsCurrent(dest, "2.4.1") { + t.Fatal("2.2.1 backend reported current under a 2.4.1 launcher") + } +} + +func TestExtractReplacesStaleTree(t *testing.T) { + dest := filepath.Join(t.TempDir(), "backend") + if err := Extract(dest, "2.2.1"); err != nil { + t.Fatalf("Extract: %v", err) + } + stray := filepath.Join(dest, "stale_module.py") + if err := os.WriteFile(stray, []byte("removed"), 0o644); err != nil { + t.Fatal(err) + } + if err := Extract(dest, "2.4.1"); err != nil { + t.Fatalf("re-extract: %v", err) + } + if _, err := os.Stat(stray); !os.IsNotExist(err) { + t.Fatal("stale file survived re-extract") + } + if !IsCurrent(dest, "2.4.1") { + t.Fatal("re-extract did not restamp") + } +} diff --git a/cli/internal/update/update.go b/cli/internal/update/update.go index 07d0ec8..65a990d 100644 --- a/cli/internal/update/update.go +++ b/cli/internal/update/update.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strconv" @@ -162,10 +163,24 @@ func Run(current string) int { printSelfUpdateFailure(os.Stderr, err) return 1 } + if err := refreshRuntime(managedBin()); err != nil { + fmt.Fprintf(os.Stderr, "podcli: binary updated, but refreshing the runtime failed (%v).\n", err) + fmt.Fprintln(os.Stderr, "The next `podcli` run will retry, or run `podcli setup --refresh` now.") + return 1 + } fmt.Printf("Updated to podcli %s.\n", tag) return 0 } +// refreshRuntime re-provisions the version-bound artifacts under runtime/ (Python +// backend, its deps, studio and Remotion bundles). It shells out to the binary we +// just installed because the new backend is embedded in that binary, not this one. +func refreshRuntime(bin string) error { + cmd := exec.Command(bin, "setup", "--refresh") + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} + func printSelfUpdateFailure(w io.Writer, err error) { fmt.Fprintf(w, "podcli: self-update failed (%v).\n", err) fmt.Fprintln(w, "Your installed podcli was left unchanged.") diff --git a/cli/main.go b/cli/main.go index 084e95f..b6b7e6b 100644 --- a/cli/main.go +++ b/cli/main.go @@ -20,9 +20,6 @@ import ( "podcli/internal/update" ) -// Version is set at build time via -ldflags "-X main.Version=...". -var Version = "2.2.1" - func main() { os.Setenv("PODCLI_VERSION", Version) args := os.Args[1:] @@ -45,6 +42,12 @@ func main() { if len(args) >= 2 && args[1] == "install" { os.Exit(mcpInstall()) } + // Safe on this path despite stdout being the JSON-RPC channel: it only writes + // to stderr. Skipping it would strand MCP-only clients on a stale backend. + if err := refreshBackend(); err != nil { + fmt.Fprintln(os.Stderr, "podcli:", err) + os.Exit(1) + } code, err := engine.RunMCP() if err != nil { fmt.Fprintln(os.Stderr, "podcli:", err) @@ -99,15 +102,38 @@ func refreshStudioBundles() { } // ensureRuntime self-provisions on first run so `podcli` works without a separate -// `podcli setup`. Not called on the mcp path, whose stdout is the JSON-RPC channel. +// `podcli setup`. Not called on the mcp path, whose stdout is the JSON-RPC channel; +// that path calls refreshBackend directly, which only writes to stderr. func ensureRuntime() error { if _, ok := engine.BackendRoot(); !ok { fmt.Fprintln(os.Stderr, "First run - setting up podcli (one-time download)...") setup(nil) + } else if err := refreshBackend(); err != nil { + return err } return ensureBackendDeps() } +// refreshBackend re-extracts the embedded backend after a launcher upgrade, since +// `podcli update` replaces only the binary. Must run before ensureBackendDeps: the +// dep stamp hashes the backend's own requirements-runtime.txt, so a stale tree +// pins a stale dep set. +func refreshBackend() error { + managed := filepath.Join(paths.RuntimeDir(), "backend") + root, _ := engine.BackendRoot() + if root != managed { + return nil // repo checkout or PODCLI_BACKEND override: the user's tree, not ours + } + if backend.IsCurrent(managed, Version) { + return nil + } + fmt.Fprintf(os.Stderr, "Updating backend to podcli %s...\n", Version) + if err := backend.Extract(managed, Version); err != nil { + return fmt.Errorf("could not update the Python backend: %w\n run `podcli setup --refresh` to retry", err) + } + return nil +} + func ensureBackendDeps() error { if !engine.IsHermeticPython() { return nil @@ -133,6 +159,9 @@ func runEngine(args []string) int { fmt.Fprintln(os.Stderr, "podcli:", err) return 1 } + } else if err := refreshBackend(); err != nil { + fmt.Fprintln(os.Stderr, "podcli:", err) + return 1 } if wantsStudio(args) { refreshStudioBundles() @@ -221,6 +250,7 @@ func setup(args []string) int { size := "base" vad := false speakers := false + refresh := false for i := 0; i < len(args); i++ { switch args[i] { case "--model": @@ -232,35 +262,45 @@ func setup(args []string) int { vad = true case "--speakers": speakers = true + case "--refresh": + refresh = true } } fmt.Printf("Provisioning into %s\n", paths.Home()) - p, err := provision.EnsureModel(size) - if err != nil { - fmt.Fprintln(os.Stderr, "podcli: setup:", err) - return 1 + // Extracted from the binary, so it costs nothing and can't fail on a bad network. + // First, so an interrupted or offline setup still leaves a backend matching this + // launcher rather than the one a previous release installed. + backendDir := filepath.Join(paths.RuntimeDir(), "backend") + if err := backend.Extract(backendDir, Version); err != nil { + fmt.Fprintf(os.Stderr, " backend: skipped (%v) - falling back to repo/PODCLI_BACKEND\n", err) + backendDir, _ = engine.BackendRoot() + } else { + fmt.Printf(" backend: %s\n", backendDir) } - fmt.Printf(" model: %s\n", p) - if vad { - vp, err := provision.EnsureVADModel() + // --refresh re-provisions what a launcher upgrade invalidates. It must not pull a + // model: the user's chosen size isn't known here, and defaulting to base would + // download one they never asked for. + if !refresh { + p, err := provision.EnsureModel(size) if err != nil { fmt.Fprintln(os.Stderr, "podcli: setup:", err) return 1 } - fmt.Printf(" vad: %s\n", vp) + fmt.Printf(" model: %s\n", p) + if vad { + vp, err := provision.EnsureVADModel() + if err != nil { + fmt.Fprintln(os.Stderr, "podcli: setup:", err) + return 1 + } + fmt.Printf(" vad: %s\n", vp) + } } if fp, err := provision.EnsureFFmpeg(); err != nil { fmt.Fprintf(os.Stderr, " ffmpeg: skipped (%v) - backend will use PATH ffmpeg\n", err) } else { fmt.Printf(" ffmpeg: %s\n", fp) } - backendDir := filepath.Join(paths.RuntimeDir(), "backend") - if err := backend.Extract(backendDir); err != nil { - fmt.Fprintf(os.Stderr, " backend: skipped (%v) - falling back to repo/PODCLI_BACKEND\n", err) - backendDir, _ = engine.BackendRoot() - } else { - fmt.Printf(" backend: %s\n", backendDir) - } if backendDir != "" { reqs := filepath.Join(backendDir, "requirements-runtime.txt") if pb, err := provision.EnsurePython(reqs); err != nil { @@ -559,6 +599,23 @@ Options: --purge Kept for compatibility; uninstall already removes everything`) } +// backendStamp annotates an unmanaged or out-of-date backend, the drift the +// launcher version alone can't show: it exports PODCLI_VERSION into the backend, +// so a stale backend reports the launcher's version as its own. +func backendStamp(root string) string { + if root != filepath.Join(paths.RuntimeDir(), "backend") { + return " (unmanaged - repo or PODCLI_BACKEND)" + } + switch v := backend.Version(root); v { + case Version: + return "" + case "": + return " (STALE: unstamped - run `podcli setup --refresh`)" + default: + return fmt.Sprintf(" (STALE: %s - run `podcli setup --refresh`)", v) + } +} + func doctor() { fmt.Printf("podcli %s\n\n", Version) fmt.Println("Paths") @@ -571,7 +628,7 @@ func doctor() { } fmt.Println("\nEngine resolution") if root, ok := engine.BackendRoot(); ok { - fmt.Printf(" backend: %s\n", root) + fmt.Printf(" backend: %s%s\n", root, backendStamp(root)) } else { fmt.Printf(" backend: NOT FOUND (set PODCLI_BACKEND or run inside the repo)\n") } @@ -664,6 +721,7 @@ Launcher commands: uninstall Remove podcli app files (keeps user data unless --purge) setup [--model base] [--vad] [--speakers] Provision runtimes + models (--speakers adds pyannote+torch, ~2GB) + setup --refresh Re-provision runtimes for this launcher version, skipping models mcp Run the MCP server (stdio) for Claude/Codex mcp install Register the MCP server with Claude Code config set update.auto off Disable auto-update (also: PODCLI_NO_UPDATE=1) diff --git a/cli/version.go b/cli/version.go new file mode 100644 index 0000000..0a40550 --- /dev/null +++ b/cli/version.go @@ -0,0 +1,15 @@ +package main + +import ( + _ "embed" + "strings" +) + +//go:generate sh gen-version.sh +//go:embed VERSION +var versionFile string + +// Version is generated from package.json by gen-version.sh, so a release bumps one +// file. It is deliberately not wired to -ldflags "-X main.Version": the linker +// applies -X only to a constant-initialized string, and silently ignores it here. +var Version = strings.TrimSpace(versionFile) diff --git a/cli/version_test.go b/cli/version_test.go new file mode 100644 index 0000000..0de4c67 --- /dev/null +++ b/cli/version_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// The embedded VERSION is generated from package.json. If someone bumps one without +// running `go generate`, the launcher ships a version that disagrees with the repo, +// which is how a v2.2.1 default survived into the v2.4.0 release. +func TestVersionMatchesPackageJSON(t *testing.T) { + b, err := os.ReadFile("../package.json") + if err != nil { + t.Fatalf("read package.json: %v", err) + } + var pkg struct { + Version string `json:"version"` + } + if err := json.Unmarshal(b, &pkg); err != nil { + t.Fatalf("parse package.json: %v", err) + } + if pkg.Version == "" { + t.Fatal("package.json has no version") + } + if Version != pkg.Version { + t.Fatalf("embedded VERSION = %q, package.json = %q - run `go generate ./...` in cli/", Version, pkg.Version) + } +} + +// The stamp comparison and the update check are string equality, so a stray newline +// from VERSION would make every backend look stale and every release look newer. +func TestVersionIsTrimmed(t *testing.T) { + if Version == "" { + t.Fatal("Version is empty") + } + if strings.TrimSpace(Version) != Version { + t.Fatalf("Version %q has surrounding whitespace", Version) + } +} diff --git a/package.json b/package.json index d387a47..077cc29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "podcli", - "version": "2.4.0", + "version": "2.4.1", "description": "AI-powered podcast clip generator for TikTok/YouTube Shorts. Transcribe, find viral moments, export vertical clips with burned captions.", "type": "module", "license": "AGPL-3.0-only", diff --git a/src/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx index af50159..233b831 100644 --- a/src/ui/client/EpisodeWorkspace.jsx +++ b/src/ui/client/EpisodeWorkspace.jsx @@ -1027,7 +1027,7 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); const data = await api('/batch-clips', { method: 'POST', body: JSON.stringify({ video_path: vp, - clips: sc.map(c => ({ start_second: c.start_second, end_second: c.end_second, title: c.title.slice(0, 40), caption_style: captionStyle, crop_strategy: cropStrategy, format })), + clips: sc.map(c => ({ start_second: c.start_second, end_second: c.end_second, title: c.title, caption_style: captionStyle, crop_strategy: cropStrategy, format })), transcript_words: transcript?.words || [], logo_path: logoPath || undefined, outro_path: outroPath || undefined, intro_path: introPath || undefined, clean_fillers: cleanFillers || undefined, }) }); @@ -1046,7 +1046,7 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); const data = await api('/create-clip', { method: 'POST', body: JSON.stringify({ video_path: vp, start_second: c.start_second, end_second: c.end_second, - title: c.title.slice(0, 40), caption_style: captionStyle, crop_strategy: cropStrategy, format, + title: c.title, caption_style: captionStyle, crop_strategy: cropStrategy, format, transcript_words: transcript?.words || [], logo_path: logoPath || undefined, outro_path: outroPath || undefined, intro_path: introPath || undefined, clean_fillers: cleanFillers || undefined, }) }); diff --git a/src/ui/client/HighlightsPage.tsx b/src/ui/client/HighlightsPage.tsx index a19142c..28428cd 100644 --- a/src/ui/client/HighlightsPage.tsx +++ b/src/ui/client/HighlightsPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; import { PageHeader } from "./Page"; -import { api, basename } from "./lib"; +import { api, basename, fmt } from "./lib"; import { useJob } from "./useJob"; import AssetPicker from "./AssetPicker"; import RecentSources from "./RecentSources"; @@ -51,11 +51,6 @@ const FORMAT_LABEL: Record = { square: "1:1", }; -const mmss = (s: number) => { - const t = Math.max(0, s); - return `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; -}; - function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -187,7 +182,7 @@ function MomentTrim({ />
- {saving ? "saving…" : `${mmss(start)}-${mmss(end)} · ${Math.round(end - start)}s`} + {saving ? "saving…" : `${fmt(start)}-${fmt(end)} · ${Math.round(end - start)}s`} @@ -597,7 +592,7 @@ export default function HighlightsPage() { }} > {idx + 1} - {mmss(m.start)}-{mmss(m.end)} + {fmt(m.start)}-{fmt(m.end)} {m.why} {(session.sources?.length ?? 1) > 1 && m.source && ( diff --git a/src/ui/client/lib.test.ts b/src/ui/client/lib.test.ts new file mode 100644 index 0000000..5100f0d --- /dev/null +++ b/src/ui/client/lib.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { fmt, fmtMs } from "./lib"; + +describe("fmt", () => { + it("formats sub-hour timestamps as m:ss", () => { + expect(fmt(0)).toBe("0:00"); + expect(fmt(9)).toBe("0:09"); + expect(fmt(767)).toBe("12:47"); + expect(fmt(3599)).toBe("59:59"); + }); + + // A podcast past the hour mark rendered as "78:31" instead of "1:18:31". + it("rolls into hours", () => { + expect(fmt(3600)).toBe("1:00:00"); + expect(fmt(4711)).toBe("1:18:31"); + expect(fmt(7325)).toBe("2:02:05"); + }); + + it("clamps negatives", () => { + expect(fmt(-5)).toBe("0:00"); + }); +}); + +describe("fmtMs", () => { + it("appends milliseconds", () => { + expect(fmtMs(12.5)).toBe("0:12.500"); + expect(fmtMs(4711.25)).toBe("1:18:31.250"); + }); +}); diff --git a/src/ui/client/lib.ts b/src/ui/client/lib.ts index 1796059..eee7bbc 100644 --- a/src/ui/client/lib.ts +++ b/src/ui/client/lib.ts @@ -1,7 +1,15 @@ import type { CSSProperties } from "react"; -export const fmt = (s: number) => - `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`; +// Rolls into hours past 3600s: podcast timestamps ran past "78:31" without it. +export const fmt = (s: number) => { + const t = Math.max(0, s); + const hours = Math.floor(t / 3600); + const minutes = Math.floor((t % 3600) / 60); + const seconds = Math.floor(t % 60); + const mm = String(minutes).padStart(2, "0"); + const ss = String(seconds).padStart(2, "0"); + return hours > 0 ? `${hours}:${mm}:${ss}` : `${minutes}:${ss}`; +}; export const fmtMs = (s: number) => `${fmt(s)}.${String(Math.floor((s % 1) * 1000)).padStart(3, "0")}`; diff --git a/src/ui/public/css/styles.css b/src/ui/public/css/styles.css index 67fd9de..c1128fa 100644 --- a/src/ui/public/css/styles.css +++ b/src/ui/public/css/styles.css @@ -648,7 +648,7 @@ select { .checkbox:hover { border-color: var(--accent); } .checkbox.checked { background: var(--accent); border-color: var(--accent); } .clip-info { flex: 1; min-width: 0; } -.clip-title { font-size: 13px; font-weight: 600; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.clip-title { font-size: 13px; font-weight: 600; line-height: 1.4; overflow-wrap: anywhere; } .clip-meta { font-size: 12px; color: var(--text2); margin-top: 2px; } .clip-meta .err { color: var(--red); } .clip-actions { display: flex; gap: 4px; flex-shrink: 0; } diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts index e350add..ac85987 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -3289,7 +3289,7 @@ app.post("/api/mcp/export", async (req, res) => { const styledClips = clips.map((c: any) => ({ start_second: c.start_second, end_second: c.end_second, - title: (c.title || "clip").slice(0, 40), + title: c.title || "clip", caption_style: c.caption_style || captionStyle, crop_strategy: c.crop_strategy || cropStrategy, format: c.format || format, diff --git a/tests/test_text_utils.py b/tests/test_text_utils.py new file mode 100644 index 0000000..3a0075b --- /dev/null +++ b/tests/test_text_utils.py @@ -0,0 +1,44 @@ +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend")) + +from utils.text import clean_title, truncate_title # noqa: E402 + + +def test_clean_title_keeps_full_text(): + long_title = "There are aerospace companies where for $300 you have the same part" + assert clean_title(long_title) == long_title + + +def test_clean_title_collapses_whitespace(): + assert clean_title(" So here I am,\n24 years old ") == "So here I am, 24 years old" + + +def test_clean_title_handles_empty(): + assert clean_title("") == "" + assert clean_title(None) == "" + + +def test_truncate_title_leaves_short_titles_alone(): + assert truncate_title("Short title") == "Short title" + + +def test_truncate_title_never_cuts_mid_word(): + # The reported bug: a raw [:55] slice produced "...you have t". + title = "There are aerospace companies where for $300 you have the same part" + out = truncate_title(title) + assert out == "There are aerospace companies where for $300 you have…" + assert len(out) <= 56 # 55 chars plus the ellipsis + assert not out.rstrip("…").endswith(" ") + + +def test_truncate_title_strips_trailing_punctuation(): + assert truncate_title("So here I am, 24 years old, and they're like: we are, flying") == ( + "So here I am, 24 years old, and they're like: we are…" + ) + + +def test_truncate_title_hard_cuts_a_single_long_word(): + out = truncate_title("x" * 80) + assert out == "x" * 55 + "…"