diff --git a/backend/migration/firebaseToAmber.py b/backend/migration/firebaseToAmber.py index 71230a4a..4b1ed927 100644 --- a/backend/migration/firebaseToAmber.py +++ b/backend/migration/firebaseToAmber.py @@ -14,78 +14,95 @@ """ import json -import uuid import sys +import uuid from datetime import datetime # CONFIG filename = sys.argv[1].strip() tenant = sys.argv[2].strip() +batch_size = 20 # max rows per INSERT statement, to stay within query length limits with open(filename, encoding='utf-8') as f: data = json.load(f) +def chunks(ids): + """Split a list of ids into batches of at most batch_size.""" + return [ids[i:i + batch_size] for i in range(0, len(ids), batch_size)] + +def esc(value): + """Escape a value for embedding in a double-quoted MariaDB string literal. + Backslashes must be escaped before quotes, otherwise quote-escaping would + introduce new backslashes that get wrongly escaped again.""" + return str(value).replace('\\', '\\\\').replace('"', '\\"') + +query = '' + # Handle users table users = {} -query = 'INSERT INTO `users` (`id`, `name`, `email`, `credential_hash`) VALUES ' -for id in data['users']: - query += '\n ("' + id + '", "' + data['users'][id]['name'] + '", "' + data['users'][id]['email'] + '", NULL),' - users[id] = data['users'][id]['email'] - -query = query[:-1] + ';\n\n' +for batch in chunks(list(data['users'])): + query += 'INSERT INTO `users` (`id`, `name`, `email`, `credential_hash`) VALUES ' + for id in batch: + query += '\n ("' + esc(id) + '", "' + esc(data['users'][id]['name']) + '", "' + esc(data['users'][id]['email']) + '", NULL),' + users[id] = data['users'][id]['email'] + query = query[:-1] + ';\n\n' # Handle roles/permissions -query += 'INSERT INTO `roles` (`user`, `tenant`, `roles`) VALUES ' -for id in data['users']: - query += '\n ("' + id + '", "' + tenant + '", "reader"),' -query = query[:-1] + ';\n\n' +# TODO: map actual roles +for batch in chunks(list(data['users'])): + query += 'INSERT INTO `roles` (`user`, `tenant`, `roles`) VALUES ' + for id in batch: + query += '\n ("' + esc(id) + '", "' + esc(tenant) + '", "reader"),' + query = query[:-1] + ';\n\n' # Handle setlists collection -query += 'INSERT INTO `documents` (`tenant`, `collection`, `id`, `change_number`, `change_user`, `change_time`, `data`, `tags`, `access_tags`) VALUES ' -for id in data['setlists']: - amber_id = uuid.uuid4().hex - change_user = data['setlists'][id]['creator'] - change_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - is_public = (not data['setlists'][id]['private']) if 'private' in data['setlists'][id] else True - setlist_data = json.dumps({ - 'active': data['setlists'][id]['active'], - 'createdBy': data['setlists'][id]['creator'], - 'date': data['setlists'][id]['date'], - 'isPublic': is_public, - 'position': data['setlists'][id]['position'], - 'sharedWith': [], - 'slug': id, - 'songs': [{ 'id': s['id'][:32], 'key': s['tuning'] } for s in data['setlists'][id]['songs']], - 'title': data['setlists'][id]['title'], - }, ensure_ascii=False).replace('"', '\\"') - access_tags = 'o-' + data['setlists'][id]['creator'] + (' public' if is_public else '') - query += '\n ("' + tenant + '", "setlists", "' + amber_id + '", 1, "' + change_user + '", "' + change_time + '", "' + setlist_data + '", "", "' + access_tags + '"),' -query = query[:-1] + ';\n\n' +for batch in chunks(list(data['setlists'])): + query += 'INSERT INTO `documents` (`tenant`, `collection`, `id`, `change_number`, `change_user`, `change_time`, `data`, `tags`, `access_tags`) VALUES ' + for id in batch: + amber_id = uuid.uuid4().hex + change_user = data['setlists'][id]['creator'] + change_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + is_public = (not data['setlists'][id]['private']) if 'private' in data['setlists'][id] else True + setlist_data = json.dumps({ + 'active': data['setlists'][id]['active'], + 'createdBy': data['setlists'][id]['creator'], + 'date': data['setlists'][id]['date'], + 'entries': [{ 'id': s['id'][:32], 'key': s['tuning'] } for s in data['setlists'][id]['songs']], + 'isPublic': is_public, + 'position': data['setlists'][id]['position'], + 'sharedWith': [], + 'slug': id, + 'title': data['setlists'][id]['title'], + }, ensure_ascii=False) + access_tags = 'o-' + data['setlists'][id]['creator'] + (' public' if is_public else '') + query += '\n ("' + esc(tenant) + '", "setlists", "' + esc(amber_id) + '", 1, "' + esc(change_user) + '", "' + esc(change_time) + '", "' + esc(setlist_data) + '", "", "' + esc(access_tags) + '"),' + query = query[:-1] + ';\n\n' # Handle songs collection -query += 'INSERT INTO `documents` (`tenant`, `collection`, `id`, `change_number`, `change_user`, `change_time`, `data`, `tags`, `access_tags`) VALUES ' -for i, id in enumerate(data['songs']): - amber_id = id[:32] - change_user = 'NULL' - change_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - song_data = json.dumps({ - 'authors': data['songs'][id]['authors'].split(' | '), - 'ccli': data['songs'][id]['ccli'], - 'content': data['songs'][id]['content'].replace('"', '\'').replace('\n', '\\n'), - 'createdBy': None, - 'key': data['songs'][id]['tuning'], - 'language': data['songs'][id]['language'], - 'publisher': data['songs'][id]['publisher'].replace('\n', '\\n'), - 'slug': id, - 'subtitle': data['songs'][id]['subtitle'], - 'tags': data['songs'][id]['tags'], - 'title': data['songs'][id]['title'], - 'translations': data['songs'][id]['translations'], - 'year': data['songs'][id]['year'], - 'youtube': data['songs'][id]['youtube'], - }, ensure_ascii=False).replace('"', '\\"') - query += '\n ("' + tenant + '", "songs", "' + amber_id + '", 1, ' + change_user + ', "' + change_time + '", "' + song_data + '", NULL, NULL),' -query = query[:-1] + ';\n\n' +for batch in chunks(list(data['songs'])): + query += 'INSERT INTO `documents` (`tenant`, `collection`, `id`, `change_number`, `change_user`, `change_time`, `data`, `tags`, `access_tags`) VALUES ' + for id in batch: + amber_id = id[:32] + change_user = 'NULL' + change_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + song_data = json.dumps({ + 'authors': data['songs'][id]['authors'].split(' | '), + 'ccli': data['songs'][id]['ccli'], + 'content': data['songs'][id]['content'], + 'createdBy': None, + 'key': data['songs'][id]['tuning'], + 'language': data['songs'][id]['language'], + 'publisher': data['songs'][id]['publisher'], + 'slug': id, + 'subtitle': data['songs'][id]['subtitle'], + 'tags': data['songs'][id]['tags'], + 'title': data['songs'][id]['title'], + 'translations': data['songs'][id]['translations'], + 'year': data['songs'][id]['year'], + 'youtube': data['songs'][id]['youtube'], + }, ensure_ascii=False) + query += '\n ("' + esc(tenant) + '", "songs", "' + esc(amber_id) + '", 1, ' + change_user + ', "' + esc(change_time) + '", "' + esc(song_data) + '", NULL, NULL),' + query = query[:-1] + ';\n\n' with open("songdrive.sql", "w", encoding="utf-8") as f: f.write(query) diff --git a/backend/models.ts b/backend/models.ts index 668d82db..9d1ebdae 100644 --- a/backend/models.ts +++ b/backend/models.ts @@ -25,6 +25,15 @@ export type SetlistSong = { key: string; // Custom key (previously named 'tuning') }; +export type SetlistSlide = { + type: 'plain'; // slide content formatter; more types (e.g. 'markdown') may be added later + title: string; // Displayed slide title + content: string; // Slide content +}; + +// A setlist.entries entry: entries with an `id` are songs, entries without one are slides +export type SetlistEntry = SetlistSong | SetlistSlide; + export type SetlistEntity = { active: boolean; // If true, the setlist is currently syncing positions createdBy: string; // User id of the creator (previously named 'creator') @@ -36,7 +45,7 @@ export type SetlistEntity = { remoteText?: boolean; // Presentation: broadcast chords-visible state to synced viewers sharedWith: string[]; // List of user ids with whom this setlist is shared slug: string; // Unique setlist url slug (previously named 'id') - songs: SetlistSong[]; // List of song ids and custom keys of songs the setlist contains + entries: SetlistEntry[]; // List of songs (with custom keys) and slides the setlist contains title: string; // Displayed setlist title }; export type Setlist = { diff --git a/backend/test/access.test.ts b/backend/test/access.test.ts index 92f79b1b..061ffbbb 100644 --- a/backend/test/access.test.ts +++ b/backend/test/access.test.ts @@ -7,11 +7,11 @@ const setlist = (overrides: Partial = {}): SetlistEntity => ({ active: false, createdBy: 'owner', date: '2026-01-01', + entries: [], isPublic: false, position: 0, sharedWith: [], slug: 'a-setlist', - songs: [], title: 'A setlist', ...overrides, }); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7059fc6e..4ef2f294 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -306,7 +306,7 @@ const initialSetlist: SetlistFormData = { title: '', isPublic: true, date: '', - songs: [], + entries: [], }; // song object diff --git a/frontend/src/definitions.ts b/frontend/src/definitions.ts index d8d6f4b7..062e7bed 100644 --- a/frontend/src/definitions.ts +++ b/frontend/src/definitions.ts @@ -1,4 +1,4 @@ -import type { SongEntity, SetlistEntity, SetlistSong } from '@backend/models'; +import type { SongEntity, SetlistEntity, SetlistEntry, SetlistSlide } from '@backend/models'; import type { UserRole } from '@backend/definitions'; /** @@ -51,7 +51,7 @@ export type SongFormData = Partial & { /** * Shape of SetlistSet.vue's `initialSetlist` prop: either the blank-form - * template (just title/isPublic/date/songs, see App.vue's initialSetlist) or + * template (just title/isPublic/date/entries, see App.vue's initialSetlist) or * a full existing SetlistEntity when editing - the rest is only read when * `existing` is true. */ @@ -59,7 +59,7 @@ export type SetlistFormData = Partial & { title: string; isPublic: boolean; date: string; - songs: SetlistSong[]; + entries: SetlistEntry[]; }; /** @@ -67,6 +67,12 @@ export type SetlistFormData = Partial & { */ export type SetlistSongPresentation = SongEntity & { customTuningDelta: number; customTuning: string }; +/** + * A single entry to feed to SetlistPresent's carousel: either a hydrated song or a plain slide, + * in the setlist's original entry order. + */ +export type SetlistPresentationEntry = SetlistSongPresentation | SetlistSlide; + /** * UI theme mode */ diff --git a/frontend/src/elements/ModalDialog.vue b/frontend/src/elements/ModalDialog.vue index 265c13e2..39e78039 100644 --- a/frontend/src/elements/ModalDialog.vue +++ b/frontend/src/elements/ModalDialog.vue @@ -26,7 +26,7 @@ }" @click.stop="null" > -
+
{{ title }}
@@ -253,6 +266,10 @@ + + + {{ t('button.addSlide') }} + {{ t('button.createSetlist') }} {{ t('button.updateSetlist') }} @@ -264,6 +281,14 @@
+ + diff --git a/frontend/src/modals/SongPresent.vue b/frontend/src/modals/SongPresent.vue index 77e61056..fb30d94b 100644 --- a/frontend/src/modals/SongPresent.vue +++ b/frontend/src/modals/SongPresent.vue @@ -15,7 +15,6 @@ :chords="chords" :key-offset="keyOffset" :presentation="true" - ref="songContentRef" />
@@ -49,6 +48,7 @@ import { injectStrict, hkCancelKey, hkThemeKey } from '@/keys'; import { ref, watch, onMounted, onUnmounted, nextTick, type PropType } from 'vue'; import { useWakeLock, whenever } from '@vueuse/core'; import { useI18n } from 'vue-i18n'; +import { maximizePresentFontsize } from '@/utils.js'; import ModalDialog from '@/elements/ModalDialog.vue'; import SecondaryButton from '@/elements/SecondaryButton.vue'; import SongContent from '@/partials/SongContent.vue'; @@ -82,7 +82,6 @@ const dark = ref(true); // timeouts for resize debouncing const resizeTimeout = ref>(); -const songContentRef = ref>(); // emits const emit = defineEmits(['chords', 'closed']); @@ -90,10 +89,7 @@ const emit = defineEmits(['chords', 'closed']); // adapt presentation content to viewport const maximizeFontsize = () => { // wait for dom to be ready - nextTick(() => { - // maximize content of presented song - songContentRef.value?.maximizeFontsize(); - }); + nextTick(() => maximizePresentFontsize()); }; // handle viewport resize const resizeHandler = () => { diff --git a/frontend/src/modals/SongSet.vue b/frontend/src/modals/SongSet.vue index 3ef1f67d..3558ac59 100644 --- a/frontend/src/modals/SongSet.vue +++ b/frontend/src/modals/SongSet.vue @@ -229,7 +229,7 @@ diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 964c871e..3cd6f0a4 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -1,7 +1,7 @@ import { notify } from '@kyvg/vue3-notification'; import type { AmberCollection } from 'amber-client'; -import type { Song, SongEntity } from '@backend/models'; -import type { SongPart, ThrowableError } from '@/definitions'; +import type { Song, SongEntity, SetlistEntry, SetlistSlide } from '@backend/models'; +import type { SongPart, ThrowableError, SetlistPresentationEntry } from '@/definitions'; import de from '@/locales/de.json'; import en from '@/locales/en.json'; @@ -17,6 +17,80 @@ const isChordLine = (line: string): boolean => { return line.slice(-2) === ' '; }; +// true if a setlist entry (raw or hydrated for presentation) is a slide rather than a song. +// Raw songs carry an `id`; hydrated songs (built from SongEntity) carry a `slug` instead — slides have neither. +const isSlide = (entry: SetlistEntry | SetlistPresentationEntry): entry is SetlistSlide => + !('id' in entry) && !('slug' in entry); + +// grow/shrink the font size of every
 inside every .present element as large as possible while
+// still fitting its parent's width and, per .present group, the viewport's height.
+// Non-wrapping text (songs) and wrapping text (slides) are taken into account.
+const maximizePresentFontsize = (): void => {
+	// config
+	const WIDTH_MARGIN  = 20;
+	const HEIGHT_MARGIN = 30;
+	const MAX_FONTSIZE  = 48;
+	// all parent elements
+	for (let a of document.querySelectorAll('.present')) {
+		// all non-wrapping child elements
+		for (let b of a.querySelectorAll('pre:not(.whitespace-pre-wrap)')) {
+			let fontSize = parseInt(getComputedStyle(b).fontSize.match(/\d+/)![0]);
+			// increase font size as long as the child is still smaller than parent and not greater than max fontsize
+			let n1 = 100; // max of 100 iterations for performance reasons
+			while (b.offsetWidth < a.offsetWidth - WIDTH_MARGIN && n1 > 0 && fontSize <= MAX_FONTSIZE) {
+				b.style.fontSize = (fontSize += 2) + 'px';
+				n1--;
+			}
+			// decrease font size if the child width exceeds the parents width
+			let n2 = 100; // max of 100 iterations for performance reasons
+			while (b.offsetWidth > a.offsetWidth - WIDTH_MARGIN && n2 > 0) {
+				b.style.fontSize = (fontSize--) + 'px';
+				n2--;
+			}
+		}
+	}
+	// viewport height budget, shared by the growth pass below and the shrink pass further down
+	let vh = Math.max(document.documentElement.clientHeight, window.innerHeight || 0) - 85;
+	// grow wrapping elements towards the height budget of their .present group
+	for (let a of document.querySelectorAll('.present')) {
+		for (let b of a.querySelectorAll('pre.whitespace-pre-wrap')) {
+			let fontSize = parseInt(getComputedStyle(b).fontSize.match(/\d+/)![0]);
+			let n = 100; // max of 100 iterations for performance reasons
+			while (fontSize <= MAX_FONTSIZE && n > 0) {
+				const groupHeight = Array.from(a.querySelectorAll('pre'))
+					.reduce((sum, part) => sum + part.offsetHeight + HEIGHT_MARGIN, 0);
+				if (groupHeight >= vh) break;
+				b.style.fontSize = (fontSize += 2) + 'px';
+				n--;
+			}
+		}
+	}
+	// decrease font size of parts with greatest font size first if it doesnt fit into viewport height
+	// handle both columns
+	for (let c of document.querySelectorAll('.present')) {
+		let parts: { part: HTMLElement, size: number, height: number }[] = [];
+		for (let d of c.querySelectorAll('pre')) {
+			parts.push({
+				part: d,
+				size: parseInt(getComputedStyle(d).fontSize.match(/\d+/)![0]),
+				height: d.offsetHeight + HEIGHT_MARGIN
+			});
+		}
+		// decrease font size of parts in columns with a greater height than viewport
+		// as long as the sum of the heights of the parts is greater than the viewport height with a max of 50 iterations
+		let n = 50;
+		while (parts.map(o => o.height).reduce((p,c) => p + c, 0) > vh && n > 0) {
+			parts.sort((a, b) => b.size - a.size);
+			if (parts.length > 0) {
+				parts[0].part.style.fontSize = (parts[0].size - 3) + 'px';
+				parts[0].size = parseInt(getComputedStyle(parts[0].part).fontSize.match(/\d+/)![0]);
+				parts[0].height = parts[0].part.offsetHeight + HEIGHT_MARGIN;
+			}
+			n--;
+		}
+	}
+};
+
 // escape a value for use inside a single-quoted XML attribute
 const escapeXmlAttr = (value: string): string =>
   value.replace(/&/g, '&').replace(//g, '>').replace(/'/g, ''');
@@ -428,6 +502,8 @@ const openLyricsXML = (song: SongEntity, version: string, translatedSong: SongEn
 export {
   keyScale,
   isChordLine,
+  isSlide,
+  maximizePresentFontsize,
   parsedContent,
   download,
   humanDate,
diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue
index aaf6b7e9..88631bd5 100644
--- a/frontend/src/views/Dashboard.vue
+++ b/frontend/src/views/Dashboard.vue
@@ -90,6 +90,7 @@
 
 
diff --git a/frontend/src/widgets/WidgetSongList.vue b/frontend/src/widgets/WidgetSongList.vue
index 103bc74d..44f15723 100644
--- a/frontend/src/widgets/WidgetSongList.vue
+++ b/frontend/src/widgets/WidgetSongList.vue
@@ -82,6 +82,7 @@