Skip to content

Commit

Permalink
🖼️ Project export includes media files
Browse files Browse the repository at this point in the history
- Ignore diacritic values in scripture book search
- Fixed freeze if song number was not a string
- Context menu remove overlay from project
  • Loading branch information
vassbo committed Dec 3, 2024
1 parent 19c2f37 commit a134189
Show file tree
Hide file tree
Showing 11 changed files with 226 additions and 43 deletions.
2 changes: 2 additions & 0 deletions public/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@
"view_private": "Show private",
"import": "Import",
"export": "Export",
"imported": "Imported!",
"duplicate": "Duplicate",
"delete": "Delete",
"delete_slide": "Delete slide",
Expand Down Expand Up @@ -734,6 +735,7 @@
"export": {
"export": "Export",
"export_as": "Export {} as",
"exporting": "Exporting",
"exported": "Exported!",
"oneFile": "One file",
"selected_shows": "Selected shows",
Expand Down
21 changes: 20 additions & 1 deletion src/electron/data/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createFolder, dataFolderNames, doesPathExist, getDataFolder, getShowsFr
import { exportOptions } from "../utils/windowOptions"
import { Message } from "../../types/Socket"
import { getAllShows } from "../utils/shows"
import AdmZip from "adm-zip"

// SHOW: .show, PROJECT: .project, BIBLE: .fsb
const customJSONExtensions: any = {
Expand Down Expand Up @@ -242,7 +243,25 @@ function exportAllShows(data: any) {
// ----- PROJECT -----

export function exportProject(data: any) {
writeFile(join(data.path, data.name), ".project", JSON.stringify(data.file), "utf-8", (err: any) => doneWritingFile(err, data.path))
toApp(MAIN, {channel: "ALERT", data: "export.exporting"})

// create archive
const zip = new AdmZip()

// copy files
const files = data.file.files || []
files.forEach((path: string) => {
zip.addLocalFile(path)
});

// add project file
zip.addFile("data.json", Buffer.from(JSON.stringify(data.file)))

const outputPath = join(data.path, data.name + ".project")
zip.writeZip(outputPath, (err: any) => doneWritingFile(err, data.path));

// plain JSON
// writeFile(join(data.path, data.name), ".project", JSON.stringify(data.file), "utf-8", (err: any) => doneWritingFile(err, data.path))
}

// ----- HELPERS -----
Expand Down
61 changes: 58 additions & 3 deletions src/electron/data/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import SqliteToJson from "sqlite-to-json"
import sqlite3 from "sqlite3"
import WordExtractor from "word-extractor"
import { toApp } from ".."
import { IMPORT } from "../../types/Channels"
import { getExtension, readFileAsync, readFileBufferAsync } from "../utils/files"
import { decompress } from "./zip"
import { IMPORT, MAIN } from "../../types/Channels"
import { dataFolderNames, doesPathExist, getDataFolder, getExtension, readFileAsync, readFileBufferAsync, writeFile } from "../utils/files"
import { decompress, isZip } from "./zip"

const specialImports: any = {
powerpoint: async (files: string[]) => {
Expand Down Expand Up @@ -84,6 +84,11 @@ export async function importShow(id: any, files: string[] | null, importSettings
if (sqliteFile) files = files.filter((a) => a.endsWith(".sqlite"))
if (id === "easyworship" || id === "softprojector" || sqliteFile) importId = "sqlite"

if (id === "freeshow_project") {
importProject(files, importSettings.path)
return
}

const zip = ["zip", "probundle", "vpc", "qsp"]
let zipFiles = files.filter((a) => zip.includes(a.slice(a.lastIndexOf(".") + 1).toLowerCase()))
if (zipFiles.length) {
Expand Down Expand Up @@ -127,6 +132,56 @@ async function readFile(filePath: string, encoding: BufferEncoding = "utf8") {

const getFileName = (filePath: string) => path.basename(filePath).slice(0, path.basename(filePath).lastIndexOf("."))

// PROJECT

async function importProject(files: string[], dataPath: string) {
toApp(MAIN, {channel: "ALERT", data: "popup.importing"})

// some .project files are plain JSON and others are zip
const zipFiles: string[] = []
const jsonFiles: string[] = []
await Promise.all(files.map(async file => {
const zip = await isZip(file)
if (zip) zipFiles.push(file)
else jsonFiles.push(file)
}))

const data = await Promise.all(jsonFiles.map(async (file) => await readFile(file)))

const importPath = getDataFolder(dataPath, dataFolderNames.imports)
zipFiles.forEach(zipFile => {
let zipData = decompress([zipFile], true)
const dataFile = zipData.find(a => a.name === "data.json")
const dataContent = JSON.parse(dataFile.content)

// write files
let replacedMedia: {[key: string]: string} = {}
dataContent.files?.forEach((filePath: string) => {
// check if path already exists on the system
if (doesPathExist(filePath)) return

const fileName = path.basename(filePath)
const file = zipData.find(a => a.name === fileName)?.content
const newMediaPath = path.join(importPath, fileName)

if (!file) return
replacedMedia[filePath] = newMediaPath
writeFile(newMediaPath, file)
});

// replace files
Object.entries(replacedMedia).forEach(([oldPath, newPath]) => {
oldPath = oldPath.replace(/\\/g, '\\\\')
newPath = newPath.replace(/\\/g, '\\\\')
dataFile.content = dataFile.content.replaceAll(oldPath, newPath)
})

data.push(dataFile)
})

toApp(IMPORT, { channel: "freeshow_project", data })
}

// PROTO
// https://greyshirtguy.com/blog/propresenter-7-file-format-part-2/
// https://github.com/greyshirtguy/ProPresenter7-Proto
Expand Down
36 changes: 33 additions & 3 deletions src/electron/data/zip.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import AdmZip from "adm-zip"
import { getExtension } from "../utils/files"
import fs from 'fs'
import { toApp } from ".."
import { getExtension } from "../utils/files"

// https://www.npmjs.com/package/adm-zip

export function decompress(files: string[]) {
export function decompress(files: string[], asBuffer: boolean = false) {
let data: any[] = []

files.forEach((file) => {
Expand All @@ -26,11 +27,40 @@ export function decompress(files: string[]) {
const name = zipEntry.entryName
const extension = getExtension(name)

if (extension !== "pro") content = content.toString("utf8")
if (extension !== "pro" && (!asBuffer || extension === "json")) content = content.toString("utf8")

data.push({ content, name, extension })
})
})

return data
}

export function isZip(path: string): Promise<boolean> {
const buffer = Buffer.alloc(4);

return new Promise((resolve) => {
fs.open(path, 'r', (err, fd) => {
if (err) {
console.error(err)
resolve(false)
}

fs.read(fd, buffer, 0, 4, 0, (err, _bytesRead, buffer) => {
if (err) {
fs.close(fd, (err1) => {
console.error(err1 || err);
resolve(false);
});
return
}

if (buffer && buffer.length === 4) {
resolve((buffer[0] === 0x50 && buffer[1] === 0x4b && (buffer[2] === 0x03 || buffer[2] === 0x05 || buffer[2] === 0x07) && (buffer[3] === 0x04 || buffer[3] === 0x06 || buffer[3] === 0x08)));
} else {
resolve(false);
}
});
});
})
}
1 change: 1 addition & 0 deletions src/frontend/components/context/contextMenus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export const contextMenuLayouts: { [key: string]: string[] } = {
project_player: ["remove"],
project_show: ["private", "duplicate", "remove", "SEPERATOR", "rename"], // "delete" removed as too many users thought it just removed the show from the project
project_section: ["recolor", "SEPERATOR", "remove"],
project_overlay: ["remove"],
project_pdf: ["remove"], // "rename",
project_ppt: ["remove"], // "rename",
shows: ["newSlide", "selectAll"],
Expand Down
17 changes: 13 additions & 4 deletions src/frontend/components/drawer/bible/Scripture.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -534,12 +534,12 @@
function findBook() {
let booksList = books[firstBibleId]?.map((b: any, i: number) => ({ ...b, id: b.id || i, abbr: b.id })) || []
let lowerSearch = searchValue.toLowerCase()
let lowerSearch = formatBookSearch(searchValue)
let splittedSearch = lowerSearch.split(" ")
// search by abbreviation (id)
if (searchValue.endsWith(" ") && splittedSearch.length === 2) {
const book = booksList.find((a) => a.abbr && a.abbr.toLowerCase() === splittedSearch[0])
const book = booksList.find((a) => a.abbr && formatBookSearch(a.abbr) === splittedSearch[0])
if (book) {
updateSearchValue(book.name + " ")
return book.id
Expand Down Expand Up @@ -570,7 +570,7 @@
let matchingArray: any[] = []
booksList.forEach((book: any) => {
let bookName = book.name.toLowerCase()
let bookName = formatBookSearch(book.name)
if (bookName.includes(value) || bookName.replaceAll(" ", "").includes(value)) matchingArray.push(book)
})
Expand Down Expand Up @@ -599,7 +599,7 @@
searchValues.bookName = matchingBook.name
if (searchValues.book !== undefined && searchValues.book === matchingBook.id) return matchingBook.id
let fullMatch = searchValue.toLowerCase().includes(matchingBook.name.toLowerCase() + " ")
let fullMatch = formatBookSearch(searchValue).includes(formatBookSearch(matchingBook.name) + " ")
if (fullMatch || !autoComplete) return matchingBook.id
// auto complete
Expand All @@ -616,6 +616,15 @@
return matchingBook.id
}
function formatBookSearch(value: string) {
// replace diacritic values like á -> a & ö -> o
// https://stackoverflow.com/a/37511463/10803046
return value
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/[!()-,.]/gi, "")
.toLowerCase()
}
function hasNumber(str) {
return /\d/.test(str)
}
Expand Down
100 changes: 74 additions & 26 deletions src/frontend/components/export/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,58 +4,106 @@
import { get } from "svelte/store"
import { EXPORT } from "../../../types/Channels"
import type { Project, ProjectShowRef } from "../../../types/Projects"
import { dataPath, showsCache } from "../../stores"
import { dataPath, folders, overlays as overlayStores, showsCache } from "../../stores"
import { send } from "../../utils/request"
import { clone } from "../helpers/array"
import { loadShows } from "../helpers/setShow"
import { formatToFileName } from "../helpers/show"
import { _show } from "../helpers/shows"
import type { SlideData } from "../../../types/Show"

export async function exportProject(project: Project) {
let shows: any = {}
let missingMedia: string[] = []
// let media: any = {}
// let overlays: any = {}
let files: string[] = []
let overlays: {[key: string]: any} = {}

// get project
project = clone(project)
let projectItems = clone(project.shows)
let parentFolder = get(folders)[project.parent]?.name || ""
project.parent = "/" // place on root

// place on root
project.parent = "/"
// project items
const getProjectItems = {
show: (showRef: ProjectShowRef) => {
shows[showRef.id] = clone(get(showsCache)[showRef.id])

let refs = _show(showRef.id).layouts().ref()
let mediaIds: string[] = []

refs.forEach(ref => {
ref.forEach(({data}: {data: SlideData}) => {
// background
let background = data.background
if (background) mediaIds.push(background)

// audio
let audio = data.audio || []
mediaIds.push(...audio)

// overlays
let overlays = data.overlays || []
overlays.forEach(getOverlay)
});
})

// get media file paths
let media = _show(showRef.id).get("media")
mediaIds.forEach(id => {
getFile(media[id].path || media[id].id)
})

// TODO: timers etc.
},
video: (showRef: ProjectShowRef) => getFile(showRef.id),
image: (showRef: ProjectShowRef) => getFile(showRef.id),
audio: (showRef: ProjectShowRef) => getFile(showRef.id),
ppt: (showRef: ProjectShowRef) => getFile(showRef.id),
pdf: (showRef: ProjectShowRef) => getFile(showRef.id),
overlay: (showRef: ProjectShowRef) => getOverlay(showRef.id),
player: () => {
// do nothing
},
section: () => {
// do nothing
},
}

let projectItems = project.shows

// load shows
let showIds = projectItems.filter((a) => (a.type || "show") === "show").map((a) => a.id)
await loadShows(showIds)

projectItems.map(getItem)

// remove duplicates
files = [...new Set(files)]

// export to file
send(EXPORT, ["GENERATE"], { type: "project", path: get(dataPath), name: formatToFileName(project.name), file: { project, shows } })
send(EXPORT, ["GENERATE"], { type: "project", path: get(dataPath), name: formatToFileName(project.name), file: { project, parentFolder, shows, files, overlays } })

function getItem(showRef: ProjectShowRef) {
let type = showRef.type || "show"

if (type === "show") {
shows[showRef.id] = get(showsCache)[showRef.id]
// TODO: get media in shows
// TODO: get overlays in shows
// TODO: get audio in shows
// TODO: timers??
if (!getProjectItems[type]) {
console.log("Missing project type:", type);
return
}

if (type === "player") return

// TODO: media files
// zip them ?
getProjectItems[type](showRef)
}

missingMedia.push(showRef.id)
function getFile(path: string) {
files.push(path)
}

// if (type === "image") {
// let base64: any = await toDataURL(showRef.id)
// console.log(base64)
// media[showRef.id] = base64
// return
// }
function getOverlay(id: string) {
if (!get(overlayStores)[id] || overlays[id]) return

// video / audio
overlays[id] = clone(get(overlayStores)[id])
}

// store as base64 ?
// let base64: any = await toDataURL(showRef.id)
// media[showRef.id] = base64
}
4 changes: 2 additions & 2 deletions src/frontend/components/helpers/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,14 @@ export function sortObjectNumbers(object: {}[], key: string, reverse: boolean =
}

// sort any object.name by numbers in the front of the string
export function sortByNameAndNumber(array: any[]) {
export function sortByNameAndNumber(array: any[]) {
return array.sort((a, b) => {
let aName = ((a.quickAccess?.number || "") + " " + a.name || "").trim()
let bName = ((b.quickAccess?.number || "") + " " + b.name || "").trim()

// get only number part if available
const extractNumber = (str) => {
const match = str.match(/\d+/)
const match = str.toString().match(/\d+/)
return match ? parseInt(match[0], 10) : Infinity
}
const quickAccessNumberA = extractNumber(a.quickAccess?.number || "")
Expand Down
Loading

0 comments on commit a134189

Please sign in to comment.