Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 71 additions & 54 deletions backend/migration/firebaseToAmber.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
11 changes: 10 additions & 1 deletion backend/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion backend/test/access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ const setlist = (overrides: Partial<SetlistEntity> = {}): SetlistEntity => ({
active: false,
createdBy: 'owner',
date: '2026-01-01',
entries: [],
isPublic: false,
position: 0,
sharedWith: [],
slug: 'a-setlist',
songs: [],
title: 'A setlist',
...overrides,
});
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ const initialSetlist: SetlistFormData = {
title: '',
isPublic: true,
date: '',
songs: [],
entries: [],
};

// song object
Expand Down
12 changes: 9 additions & 3 deletions frontend/src/definitions.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -51,22 +51,28 @@ export type SongFormData = Partial<SongEntity> & {

/**
* 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.
*/
export type SetlistFormData = Partial<SetlistEntity> & {
title: string;
isPublic: boolean;
date: string;
songs: SetlistSong[];
entries: SetlistEntry[];
};

/**
* A setlist song hydrated with its full song entity plus per-setlist custom key.
*/
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
*/
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/elements/ModalDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
}"
@click.stop="null"
>
<div class="flex justify-between px-4">
<div class="flex justify-between">
<div class="text-lg uppercase font-medium">{{ title }}</div>
<slot name="close">
<button aria-label="Close" @click="emit('closed')">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/elements/SongTag.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div
class="rounded-sm text-sm flex items-center bg-blade-300 dark:bg-blade-750 hover:bg-spring-700 gap-2 py-0.5 px-2"
class="rounded-sm text-sm flex items-center bg-blade-300 dark:bg-blade-750 hover:bg-spring-400 hover:dark:bg-spring-700 gap-2 py-0.5 px-2"
>
<slot>
<icon-tag class="shrink-0 w-4 h-4 stroke-1.5" />
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
"summary": "Speichern, Synchronisieren und Präsentieren von Liedern und Setlisten"
},
"button": {
"addSlide": "Folie hinzufügen",
"assign": "Zuweisen",
"back": "Zurück",
"cancel": "Abbrechen",
"changePassword": "Passwort ändern",
"copy": "Kopieren",
"createSetlist": "Setliste Erstellen",
"createSlide": "Folie Erstellen",
"createSong": "Song Erstellen",
"delete": "Löschen",
"docsOnGithub": "Doku auf GitHub",
Expand All @@ -41,6 +43,7 @@
"signOut": "Abmelden",
"top": "Nach oben",
"updateSetlist": "Setliste Aktualisieren",
"updateSlide": "Folie Aktualisieren",
"updateSong": "Song Aktualisieren"
},
"divider": {
Expand Down Expand Up @@ -126,8 +129,10 @@
"deleteSetlist": "Setlist Löschen",
"deleteSong": "Song Löschen",
"editSetlist": "Setlist Bearbeiten",
"editSlide": "Folie Bearbeiten",
"editSong": "Song Bearbeiten",
"newSetlist": "Neue Setlist",
"newSlide": "Neue Folie",
"newSong": "Neuer Song",
"songInfo": "Song Informationen",
"songSyntaxCheatsheet": "Song-Syntax Spickzettel",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
"summary": "Store, manage, synchronize and present songs and setlists"
},
"button": {
"addSlide": "Add Slide",
"assign": "Assign",
"back": "Back",
"cancel": "Cancel",
"changePassword": "Change Password",
"copy": "Copy",
"createSetlist": "Create Setlist",
"createSlide": "Create Slide",
"createSong": "Create Song",
"delete": "Delete",
"docsOnGithub": "Docs on GitHub",
Expand All @@ -41,6 +43,7 @@
"signOut": "Sign Out",
"top": "Top",
"updateSetlist": "Update Setlist",
"updateSlide": "Update Slide",
"updateSong": "Update Song"
},
"divider": {
Expand Down Expand Up @@ -126,8 +129,10 @@
"deleteSetlist": "Delete Setlist",
"deleteSong": "Delete Song",
"editSetlist": "Edit Setlist",
"editSlide": "Edit Slide",
"editSong": "Edit Song",
"newSetlist": "New Setlist",
"newSlide": "New Slide",
"newSong": "New Song",
"songInfo": "Song Information",
"songSyntaxCheatsheet": "Song Syntax Cheatsheet",
Expand Down
Loading
Loading