Skip to content

Commit 1f32d15

Browse files
authored
Merge pull request #280 from devmount/features/255-open-lyrics-chords
Include chords for open lyrics export
2 parents e980f41 + d886c6e commit 1f32d15

2 files changed

Lines changed: 81 additions & 2 deletions

File tree

frontend/src/utils.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,52 @@ const isChordLine = (line: string): boolean => {
1717
return line.slice(-2) === ' ';
1818
};
1919

20+
// escape a value for use inside a single-quoted XML attribute
21+
const escapeXmlAttr = (value: string): string =>
22+
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/'/g, '&apos;');
23+
24+
// splice a chord line's tokens into its paired lyric line as OpenLyrics <chord> tags at their column position.
25+
// Important: lyricLine must stay unescaped until after splicing — column offsets are computed against the raw
26+
// (unescaped) text, so escaping first would shift indices and misalign chords with text.
27+
const embedChords = (chordLine: string, lyricLine: string): string => {
28+
const tokens: { name: string; column: number }[] = [];
29+
const re = /\S+/g;
30+
let match: RegExpExecArray | null;
31+
while ((match = re.exec(chordLine)) !== null) {
32+
tokens.push({ name: match[0], column: match.index });
33+
}
34+
let result = '', cursor = 0;
35+
for (const { name, column } of tokens) {
36+
result += lyricLine.slice(cursor, column); // slice clamps automatically if column > lyricLine.length
37+
result += `<chord name='${escapeXmlAttr(name)}'/>`;
38+
cursor = column;
39+
}
40+
return result + lyricLine.slice(cursor);
41+
};
42+
43+
// walk a song part's raw lines (lyrics interleaved with chord lines), pairing each chord line with the
44+
// non-blank lyric line directly beneath it and join everything with the file's existing '<br />' convention
45+
const chordTaggedLines = (content: string): string => {
46+
const lines = content.split('\n');
47+
const result: string[] = [];
48+
for (let i = 0; i < lines.length; i++) {
49+
if (isChordLine(lines[i])) {
50+
const next = lines[i + 1];
51+
if (next !== undefined && next.trim() !== '' && !isChordLine(next)) {
52+
result.push(embedChords(lines[i], next));
53+
i++;
54+
} else {
55+
// standalone/instrumental chord line (back-to-back chord lines, last line of a part, or
56+
// followed by a blank separator line): emit bare chord tags, don't swallow a blank line
57+
result.push(embedChords(lines[i], ''));
58+
}
59+
} else {
60+
result.push(lines[i]);
61+
}
62+
}
63+
return result.join('<br />');
64+
};
65+
2066
// parse song content syntax
2167
function parsedContent(content: string, keyOffset: number, showChords: boolean, twoColumns: false): SongPart[];
2268
function parsedContent(content: string, keyOffset: number, showChords: boolean, twoColumns: true): [SongPart[], SongPart[]];
@@ -367,13 +413,13 @@ const openLyricsXML = (song: SongEntity, version: string, translatedSong: SongEn
367413
? `<format><tags application='OpenLP'><tag name='it'><open><![CDATA[<em>]]></open><close><![CDATA[</em>]]></close><hidden><![CDATA[False]]></hidden></tag><tag name='gr'><open><![CDATA[<span style='-webkit-text-fill-color:grey'>]]></open><close><![CDATA[</span>]]></close><hidden><![CDATA[True]]></hidden></tag><tag name='fd'><open><![CDATA[<small>]]></open><close><![CDATA[</small>]]></close><hidden><![CDATA[True]]></hidden></tag></tags></format>`
368414
: '';
369415
const tParts = translatedSong ? parsedContent(translatedSong.content, 0, false, false) : [];
370-
const lyrics = parsedContent(song.content, 0, false, false).map((p, i) => {
416+
const lyrics = parsedContent(song.content, 0, true, false).map((p, i) => {
371417
const type = p.type ? p.type.toUpperCase() : 'V';
372418
const num = Number(p.number) > 0 ? p.number : '1';
373419
const tContent = (i in tParts)
374420
? `<br/><br/><tag name='it'><tag name='gr'><tag name='fd'>${tParts[i].content.replace(/\n/g, "<br />")}</tag></tag></tag>`
375421
: '';
376-
return `<verse name='${type}${num}'><lines>${p.content.replace(/\n/g, "<br />")}${tContent}</lines></verse>`;
422+
return `<verse name='${type}${num}'><lines>${chordTaggedLines(p.content)}${tContent}</lines></verse>`;
377423
}).join('');
378424

379425
return `<?xml version='1.0' encoding='UTF-8'?><song xmlns='http://openlyrics.info/namespace/2009/song' version='0.9' createdIn='SongDrive ${version}' modifiedIn='SongDrive ${version}' modifiedDate='${timestamp}'><properties><titles>${title}${subtitle}</titles>${copyright}${year}${ccli}${authors}${tags}</properties>${format}<lyrics>${lyrics}</lyrics></song>`;

frontend/tests/utils.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,4 +331,37 @@ describe('openLyricsXML', () => {
331331
'<verse name=\'V1\'><lines>Original line<br/><br/><tag name=\'it\'><tag name=\'gr\'><tag name=\'fd\'>Translated line</tag></tag></tag></lines></verse>'
332332
);
333333
});
334+
335+
it('embeds chords inline at their column position', () => {
336+
const song: SongEntity = { ...minimalSong, content: '--V1\nEm D \nAmazing grace' };
337+
const xml = openLyricsXML(song, '1.0.0');
338+
expect(xml).toContain(
339+
'<verse name=\'V1\'><lines><chord name=\'Em\'/>Amazing <chord name=\'D\'/>grace</lines></verse>'
340+
);
341+
});
342+
343+
it('emits a bare chord tag for an instrumental chord line with no lyric', () => {
344+
const song: SongEntity = { ...minimalSong, content: '--I\nEm ' };
345+
expect(openLyricsXML(song, '1.0.0')).toContain('<lines><chord name=\'Em\'/></lines>');
346+
});
347+
348+
it('handles two consecutive chord lines without swallowing either', () => {
349+
const song: SongEntity = { ...minimalSong, content: '--I\nEm \nD ' };
350+
expect(openLyricsXML(song, '1.0.0')).toContain(
351+
'<lines><chord name=\'Em\'/><br /><chord name=\'D\'/></lines>'
352+
);
353+
});
354+
355+
it('preserves a blank separator line after an orphan chord line in markerless content', () => {
356+
// no '--' marker at all, so parsedContent takes the raw-passthrough branch and doesn't
357+
// pre-strip blank lines itself, unlike the marked branch used by the other cases above
358+
const song: SongEntity = { ...minimalSong, content: 'Em \n\nSome lyric' };
359+
const xml = openLyricsXML(song, '1.0.0');
360+
expect(xml).toContain('<chord name=\'Em\'/><br /><br />Some lyric');
361+
});
362+
363+
it('escapes special characters in chord names', () => {
364+
const song: SongEntity = { ...minimalSong, content: '--V1\nA&B \nline' };
365+
expect(openLyricsXML(song, '1.0.0')).toContain('<chord name=\'A&amp;B\'/>');
366+
});
334367
});

0 commit comments

Comments
 (0)