Skip to content

Commit

Permalink
feat: Import/Export Panels and widgets (#150)
Browse files Browse the repository at this point in the history
  • Loading branch information
skarab42 committed Feb 12, 2021
1 parent 9610902 commit a46e756
Show file tree
Hide file tree
Showing 16 changed files with 390 additions and 22 deletions.
2 changes: 2 additions & 0 deletions app/package.json
Expand Up @@ -19,8 +19,10 @@
"env-paths": "^2.2.0",
"fs-extra": "^9.0.1",
"get-system-fonts": "^2.0.2",
"human-id": "^2.0.1",
"i18next": "^19.8.3",
"i18next-fs-backend": "^1.0.7",
"jszip": "^3.6.0",
"mime": "^2.4.6",
"obs-websocket-js": "^4.0.2",
"open": "^7.3.0",
Expand Down
11 changes: 11 additions & 0 deletions app/server/api/panels.js
Expand Up @@ -51,4 +51,15 @@ module.exports = {
this.notify("panels.update", payload.panel);
return payload;
},
exportWidget(panel, widget) {
return panels.exportWidget(panel, widget);
},
exportPanel(panel) {
return panels.exportPanel(panel);
},
async importArchive(panel, widget) {
const payload = await panels.importArchive(panel, widget);
this.notify(`panels.${payload.event}`, payload.panel);
return payload;
},
};
186 changes: 178 additions & 8 deletions app/server/libs/panels.js
@@ -1,8 +1,13 @@
const { panels: store } = require("../../stores");
const { filesPath } = require("../../utils");
const cloneDeep = require("clone-deep");
const { humanId } = require("human-id");
const actions = require("./actions");
const { v4: uuid } = require("uuid");
const { _ } = require("./i18next");
const JSZip = require("jszip");
const path = require("path");
const fs = require("fs");

let panels = getAll();

Expand All @@ -14,17 +19,19 @@ function name(id) {
return `${_("sentences.powers-group")} #${id.slice(0, 4)}`;
}

function create() {
function create(panel = {}) {
const id = uuid();
return {
id,
name: name(id),
widgets: [],
grid: [],
...panel,
id,
};
}

function createWidget() {
function createWidget(widget = {}) {
widget && delete widget.id;
return {
id: uuid(),
component: null,
Expand All @@ -40,11 +47,12 @@ function createWidget() {
backgroundColor: "#553C9A",
backgroundImage: null,
borders: "rounded",
...widget,
};
}

function add() {
let panel = create();
function add(panel = {}) {
panel = create(panel);
panels.push(panel);
store.set("panels", panels);
return panel;
Expand Down Expand Up @@ -90,10 +98,10 @@ function findWidgetById(panel, id) {
return panel.widgets.find((w) => w.id === id);
}

function addWidget(panel, item) {
let widget = createWidget();
function addWidget(panel, item, widget = {}) {
widget = createWidget(widget);
const oldPanel = findPanelById(panel.id);
oldPanel.grid.push({ id: widget.id, ...item });
oldPanel.grid.push({ ...item, id: widget.id });
oldPanel.widgets.push(widget);
return { panel: update(oldPanel), widget, item };
}
Expand Down Expand Up @@ -157,14 +165,176 @@ function removeWidget(panel, widget) {
return { panel: update(oldPanel), widget };
}

function readAssetFile(filename) {
return fs.readFileSync(path.join(filesPath, filename));
}

async function exportArchive(type, store, files) {
const zip = new JSZip();
const filename = `${humanId()}.marv-${type}`;

zip.file("store.json", JSON.stringify(store));

files.forEach((filename) =>
zip.file(`files/${filename}`, readAssetFile(filename))
);

const buffer = await zip.generateAsync({ type: "nodebuffer" });
return { filename, buffer };
}

async function exportPanel(panel) {
let files = [];
let panelActions = {};

panel.widgets.forEach((widget) => {
const action = actions.get(widget.id);
action && (panelActions[widget.id] = action);
files = [...files, ...getWidgetFiles({ widget, action })];
});

return await exportArchive("panel", { panel, actions: panelActions }, files);
}

function getWidgetFiles({ widget, action }) {
const files = [];

if (widget.backgroundImage) {
files.push(widget.backgroundImage);
}

action &&
action.items.forEach(({ target }) => {
files.push(target.filename);
});

return files;
}

async function exportWidget(panel, widget) {
const bbox = panel.grid.find((item) => item.id === widget.id);
const action = actions.get(widget.id);
const store = { widget, bbox, action };
const files = getWidgetFiles(store);

return exportArchive("widget", store, files);
}

async function saveWidgetAsset(relativePath, file) {
let filepath = path.join(filesPath, relativePath);
const buffer = await file.async("nodebuffer");
const renamed = fs.existsSync(filepath);
const oldPath = relativePath;
if (renamed) {
relativePath = `${humanId()}${path.extname(relativePath)}`;
filepath = path.join(filesPath, relativePath);
}
fs.writeFileSync(filepath, buffer);
return { renamed, oldPath, newPath: relativePath };
}

async function saveZipFiles(files) {
const promises = [];

files.forEach((relativePath, file) => {
promises.push(saveWidgetAsset(relativePath, file));
});

return await Promise.all(promises);
}

async function loadArchive(buffer) {
const jszip = new JSZip();
const zip = await jszip.loadAsync(buffer);
const store = await zip.file("store.json").async("string");
const files = await zip.folder("files");

return { store: JSON.parse(store), files };
}

function renameWidgetFiles(results, { widget, action }) {
results.forEach(({ renamed, newPath, oldPath }) => {
if (!renamed) return;
if (widget.backgroundImage === oldPath) {
widget.backgroundImage = newPath;
}
if (action) {
action.items = action.items.map((item) => {
if (item.target.filename === oldPath) {
item.target.filename = newPath;
}
item.keyframes = item.keyframes.map((keyframe) => ({
...keyframe,
id: uuid(),
}));
return { ...item, id: uuid() };
});
}
});
}

async function importWidget(panel, { buffer, position }) {
const { store, files } = await loadArchive(buffer);
const results = await saveZipFiles(files);

renameWidgetFiles(results, store);

const result = addWidget(panel, position, store.widget);

if (store.action) {
actions.update({ widget: result.widget, anime: store.action });
}

return { event: "update", ...result };
}

async function importPanel({ buffer }) {
const { store, files } = await loadArchive(buffer);
const results = await saveZipFiles(files);
let panel = add({ name: store.panel.name });

store.panel.widgets.forEach((widget) => {
const grid = store.panel.grid.find((item) => item.id === widget.id);
const action = store.actions[widget.id];

renameWidgetFiles(results, { widget, action });

const { x, y, w, h } = grid;
const result = addWidget(panel, { x, y, w, h }, widget);
panel = result.panel;

if (action) {
action.id = result.widget.id;
actions.update({ widget: result.widget, anime: action });
}
});

return { event: "add", panel };
}

function importArchive(panel, archive) {
if (archive.filename.endsWith(".marv-widget")) {
return importWidget(panel, archive);
}

if (archive.filename.endsWith(".marv-panel")) {
return importPanel(archive);
}

return { error: "Unsupported file format" };
}

module.exports = {
add,
set,
remove,
update,
getAll,
addWidget,
exportPanel,
removeWidget,
importArchive,
exportWidget,
duplicateWidget,
moveWidgetToPanel,
removeWidgetComponent,
Expand Down
4 changes: 3 additions & 1 deletion app/static/locales/en/app.json
Expand Up @@ -145,7 +145,9 @@
"move": "move",
"duplicate": "duplicate",
"close": "close",
"confirm": "confirm"
"confirm": "confirm",
"export": "export",
"import": "import"
},
"obs": {
"scene-list": "OBS | Scene list",
Expand Down
4 changes: 3 additions & 1 deletion app/static/locales/es/app.json
Expand Up @@ -144,7 +144,9 @@
"move": "mover",
"duplicate": "duplicado",
"close": "cerrar",
"confirm": "confirmar"
"confirm": "confirmar",
"export": "exportar",
"import": "importar"
},
"obs": {
"scene-list": "OBS | Lista de escenas",
Expand Down
4 changes: 3 additions & 1 deletion app/static/locales/fr/app.json
Expand Up @@ -145,7 +145,9 @@
"move": "déplacer",
"duplicate": "dupliquer",
"close": "fermer",
"confirm": "confirmer"
"confirm": "confirmer",
"export": "exporter",
"import": "importer"
},
"obs": {
"scene-list": "OBS | Liste des scènes",
Expand Down
39 changes: 38 additions & 1 deletion app/yarn.lock
Expand Up @@ -1040,6 +1040,11 @@ http-signature@~1.2.0:
jsprim "^1.2.2"
sshpk "^1.7.0"

human-id@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/human-id/-/human-id-2.0.1.tgz#71aadd0f46d577fd982358133cfd43f2a46f1477"
integrity sha512-XWoYbGsEfBB0mtUHiyihsefgf+s1tNQHj7sX1kgDxUM0IEKk8rcZIPTwUpqDdFIQbkViOLejbc0t8jBzz5jL3w==

i18next-fs-backend@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/i18next-fs-backend/-/i18next-fs-backend-1.0.7.tgz#00ca4587e306f8948740408389dda73461a5d07f"
Expand All @@ -1066,6 +1071,11 @@ ignore-walk@^3.0.1:
dependencies:
minimatch "^3.0.4"

immediate@~3.0.5:
version "3.0.6"
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=

indexof@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
Expand Down Expand Up @@ -1329,6 +1339,16 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"

jszip@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.6.0.tgz#839b72812e3f97819cc13ac4134ffced95dd6af9"
integrity sha512-jgnQoG9LKnWO3mnVNBnfhkh0QknICd1FGSrXcgrl67zioyJ4wgx25o9ZqwNtrROSflGBCGYnJfjrIyRIby1OoQ==
dependencies:
lie "~3.3.0"
pako "~1.0.2"
readable-stream "~2.3.6"
set-immediate-shim "~1.0.1"

kind-of@^6.0.2:
version "6.0.3"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
Expand All @@ -1339,6 +1359,13 @@ klona@^2.0.4:
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.4.tgz#7bb1e3affb0cb8624547ef7e8f6708ea2e39dfc0"
integrity sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA==

lie@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==
dependencies:
immediate "~3.0.5"

locate-path@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
Expand Down Expand Up @@ -1674,6 +1701,11 @@ p-try@^2.0.0:
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==

pako@~1.0.2:
version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==

parseqs@0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.6.tgz#8e4bb5a19d1cdc844a08ac974d34e273afa670d5"
Expand Down Expand Up @@ -1764,7 +1796,7 @@ rc@^1.2.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"

readable-stream@^2.0.6:
readable-stream@^2.0.6, readable-stream@~2.3.6:
version "2.3.7"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
Expand Down Expand Up @@ -1898,6 +1930,11 @@ set-blocking@~2.0.0:
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=

set-immediate-shim@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61"
integrity sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=

setprototypeof@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
Expand Down
3 changes: 3 additions & 0 deletions front-src/client/api/panels.js
Expand Up @@ -13,4 +13,7 @@ export default {
removeWidget: (panel, widget) => emit("panels.removeWidget", panel, widget),
removeWidgetComponent: (panel, widget) =>
emit("panels.removeWidgetComponent", panel, widget),
exportWidget: (panel, widget) => emit("panels.exportWidget", panel, widget),
exportPanel: (panel) => emit("panels.exportPanel", panel),
importArchive: (panel, widget) => emit("panels.importArchive", panel, widget),
};

0 comments on commit a46e756

Please sign in to comment.