-
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index c9f9af436..7953bee41 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -89,6 +89,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
>
From a70896b11b9d93e6d50b555473cc3b9c183dc07f Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sat, 29 Aug 2026 11:53:09 +0900
Subject: [PATCH 17/17] fix(contract): align Unicode fill validation
---
apps/desktop/core/src/lib.rs | 61 +++++++++++++++++--
.../features/workspace/firstFillPlan.test.ts | 9 +++
.../src/features/workspace/firstFillPlan.ts | 5 +-
packages/shared-types/src/index.ts | 44 ++++++++++++-
packages/shared-types/test/index.test.ts | 24 ++++++++
5 files changed, 136 insertions(+), 7 deletions(-)
diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs
index ef30abeb8..82baa3d20 100644
--- a/apps/desktop/core/src/lib.rs
+++ b/apps/desktop/core/src/lib.rs
@@ -554,12 +554,50 @@ pub fn project_payload_from_content(content: &str) -> Result bool {
+ matches!(
+ value,
+ '\u{0009}'..='\u{000D}'
+ | '\u{0020}'
+ | '\u{0085}'
+ | '\u{00A0}'
+ | '\u{1680}'
+ | '\u{2000}'..='\u{200A}'
+ | '\u{2028}'
+ | '\u{2029}'
+ | '\u{202F}'
+ | '\u{205F}'
+ | '\u{3000}'
+ | '\u{FEFF}'
+ )
+}
+
+/// Reject blank or Unicode line-separated fill guidance without normalizing user text.
+fn is_valid_fill_plan(value: &str) -> bool {
+ let mut has_non_whitespace = false;
+ for character in value.chars() {
+ if matches!(
+ character,
+ '\n' | '\r' | '\u{0085}' | '\u{2028}' | '\u{2029}'
+ ) {
+ return false;
+ }
+ if !is_plan_whitespace(character) {
+ has_non_whitespace = true;
+ }
+ }
+ has_non_whitespace
+}
+
fn validate_fill_plan(payload: RehearsalSongPayload) -> Result {
for section in &payload.sections {
for role in §ion.roles {
- if role.fill_plan.as_ref().is_some_and(|fill_plan| {
- fill_plan.trim().is_empty() || fill_plan.contains('\n') || fill_plan.contains('\r')
- }) {
+ if role
+ .fill_plan
+ .as_deref()
+ .is_some_and(|fill_plan| !is_valid_fill_plan(fill_plan))
+ {
return Err("Invalid project file format".to_string());
}
}
@@ -918,13 +956,28 @@ mod tests {
#[test]
fn project_payload_from_content_rejects_invalid_fill_plan() {
- for fill_plan in ["", " ", "fill here\nthen move", "fill here\rthen move"] {
+ for fill_plan in [
+ "",
+ " ",
+ "\u{FEFF}",
+ "\u{0085}",
+ "fill here\nthen move",
+ "fill here\rthen move",
+ "fill here\u{0085}then move",
+ "fill here\u{2028}then move",
+ "fill here\u{2029}then move",
+ ] {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
payload["sections"][0]["roles"][0]["fillPlan"] = json!(fill_plan);
let content = serde_json::to_string(&payload).expect("payload should serialize");
assert!(project_payload_from_content(&content).is_err());
}
+
+ let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
+ payload["sections"][0]["roles"][0]["fillPlan"] = json!("\u{FEFF}Fill the string\u{FEFF}");
+ let content = serde_json::to_string(&payload).expect("payload should serialize");
+ assert!(project_payload_from_content(&content).is_ok());
}
#[test]
diff --git a/apps/desktop/src/features/workspace/firstFillPlan.test.ts b/apps/desktop/src/features/workspace/firstFillPlan.test.ts
index ba0a8f020..17eb77744 100644
--- a/apps/desktop/src/features/workspace/firstFillPlan.test.ts
+++ b/apps/desktop/src/features/workspace/firstFillPlan.test.ts
@@ -131,6 +131,15 @@ describe("resolveFirstFillPlan", () => {
).toBeNull();
});
+ it("skips Unicode line separators and accepts BOM-padded fill text", () => {
+ for (const fillPlan of ["Fill\u0085here", "Fill\u2028here", "Fill\u2029here"]) {
+ expect(resolveFirstFillPlan(withFillSection({ fillPlan }))).toBeNull();
+ }
+ expect(resolveFirstFillPlan(withFillSection({ fillPlan: "\uFEFF Fill here \uFEFF" }))?.fillPlan).toBe(
+ "Fill here"
+ );
+ });
+
it("prefers the earlier of two fill plans", () => {
const song = withFillSection({
id: "verse-late",
diff --git a/apps/desktop/src/features/workspace/firstFillPlan.ts b/apps/desktop/src/features/workspace/firstFillPlan.ts
index 5c48aa4bf..6cfea5d3c 100644
--- a/apps/desktop/src/features/workspace/firstFillPlan.ts
+++ b/apps/desktop/src/features/workspace/firstFillPlan.ts
@@ -1,6 +1,7 @@
import {
MAX_SECTION_TIME_SECONDS,
SECTION_FORM_LABELS,
+ isNonEmptySingleLineText,
type RehearsalRole,
type RehearsalSection,
type RehearsalSong
@@ -117,10 +118,10 @@ function ownedFillPlan(role: unknown): string | null {
if (typeof fillPlan !== "string") {
return null;
}
- const trimmed = fillPlan.trim();
- if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) {
+ if (!isNonEmptySingleLineText(fillPlan)) {
return null;
}
+ const trimmed = fillPlan.trim();
return truncateCodePoints(trimmed, MAX_FILL_PLAN_CHARACTERS);
}
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index aad0fd7a6..742ba6b90 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -409,6 +409,48 @@ function isOneOf(options: readonly T[], value: unknown): value
return typeof value === "string" && options.includes(value as T);
}
+/** Return whether a code point is in the cross-language plan whitespace set. */
+function isPlanWhitespaceCodePoint(codePoint: number): boolean {
+ return (
+ (codePoint >= 0x0009 && codePoint <= 0x000d) ||
+ codePoint === 0x0020 ||
+ codePoint === 0x0085 ||
+ codePoint === 0x00a0 ||
+ codePoint === 0x1680 ||
+ (codePoint >= 0x2000 && codePoint <= 0x200a) ||
+ codePoint === 0x2028 ||
+ codePoint === 0x2029 ||
+ codePoint === 0x202f ||
+ codePoint === 0x205f ||
+ codePoint === 0x3000 ||
+ codePoint === 0xfeff
+ );
+}
+
+/** Apply one explicit cross-language Unicode whitespace policy to plan text. */
+export function isNonEmptySingleLineText(value: unknown): value is string {
+ if (typeof value !== "string") {
+ return false;
+ }
+ let hasNonWhitespace = false;
+ for (const character of value) {
+ const codePoint = character.codePointAt(0)!;
+ if (
+ codePoint === 0x000a ||
+ codePoint === 0x000d ||
+ codePoint === 0x0085 ||
+ codePoint === 0x2028 ||
+ codePoint === 0x2029
+ ) {
+ return false;
+ }
+ if (!isPlanWhitespaceCodePoint(codePoint)) {
+ hasNonWhitespace = true;
+ }
+ }
+ return hasNonWhitespace;
+}
+
/** Documented. */
function invalidField(path: string): string {
return `Invalid rehearsal song contract: invalid field '${path}'`;
@@ -1556,7 +1598,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null {
if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") {
return invalidField(`${path}.transpositionPlan`);
}
- if (value.fillPlan !== undefined && typeof value.fillPlan !== "string") {
+ if (value.fillPlan !== undefined && !isNonEmptySingleLineText(value.fillPlan)) {
return invalidField(`${path}.fillPlan`);
}
if (!isDenseArray(value.manualOverrides)) {
diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts
index e706dfd8b..9d860c385 100644
--- a/packages/shared-types/test/index.test.ts
+++ b/packages/shared-types/test/index.test.ts
@@ -764,6 +764,30 @@ describe("shared type helpers", () => {
expect(second.collaboration?.assignments).toHaveLength(2);
});
+ it("keeps optional fill plans aligned with Rust project loading", () => {
+ for (const fillPlan of [
+ "",
+ " ",
+ "\uFEFF",
+ "\u0085",
+ "fill here\nthen move",
+ "fill here\rthen move",
+ "fill here\u0085then move",
+ "fill here\u2028then move",
+ "fill here\u2029then move"
+ ]) {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0]!.fillPlan = fillPlan;
+
+ expect(isRehearsalSong(song)).toBe(false);
+ expect(() => parseRehearsalSong(song)).toThrow("sections[0].roles[0].fillPlan");
+ }
+
+ const paddedPlan = createDemoRehearsalSong();
+ paddedPlan.sections[0]!.roles[0]!.fillPlan = "\uFEFF Fill the string \uFEFF";
+ expect(isRehearsalSong(paddedPlan)).toBe(true);
+ });
+
it("validates and parses rehearsal song payloads", () => {
const song = createDemoRehearsalSong();
const malformedSong = createDemoRehearsalSong() as unknown as {