Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(menu/exportCommunication): Allow export of communication section #1044

Merged
merged 9 commits into from
Nov 24, 2022
9 changes: 9 additions & 0 deletions public/js/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,13 @@ export const officialPlugins = [
requireDoc: false,
position: 'bottom',
},
{
name: 'Export Communication Section',
src: '/src/menu/ExportCommunication.js',
icon: 'share',
danyill marked this conversation as resolved.
Show resolved Hide resolved
default: false,
kind: 'menu',
requireDoc: true,
position: 'middle'
}
];
19 changes: 19 additions & 0 deletions src/foundation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2797,6 +2797,25 @@ export function newLnInstGenerator(
};
}

/**
* Format xml string in "pretty print" style and return as a string
* @param xml - xml document as a string
* @param tab - character to use as a tab
* @returns string with pretty print formatting
*/
export function formatXml(xml: string, tab?: string): string {
let formatted = '',
indent = '';

if (!tab) tab = '\t';
xml.split(/>\s*</).forEach(function (node) {
if (node.match(/^\/\w/)) indent = indent.substring(tab!.length);
formatted += indent + '<' + node + '>\r\n';
if (node.match(/^<?\w[^>]*[^/]$/)) indent += tab;
});
return formatted.substring(1, formatted.length - 3);
}

declare global {
interface ElementEventMap {
['pending-state']: PendingStateEvent;
Expand Down
72 changes: 72 additions & 0 deletions src/menu/ExportCommunication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { LitElement, property } from 'lit-element';
import { get } from 'lit-translate';

import { saveXmlBlob } from './SaveProject.js';
danyill marked this conversation as resolved.
Show resolved Hide resolved
import { newLogEvent } from '../foundation.js';

function cloneAttributes(destElement: Element, sourceElement: Element) {
let attr;
const attributes = Array.prototype.slice.call(sourceElement.attributes);
while ((attr = attributes.pop())) {
destElement.setAttribute(attr.nodeName, attr.nodeValue);
}
}
ca-d marked this conversation as resolved.
Show resolved Hide resolved

/**
* Plug-in to allow exporting of the Communication SCL element as an XML file.
*/
export default class ExportCommunication extends LitElement {
/** The document being edited as provided to plugins by [[`OpenSCD`]]. */
@property({ attribute: false }) doc!: XMLDocument;
@property({ attribute: false }) docName!: string;
@property() exportBlob!: Blob | null;

/** Entry point for this plug-in */
async run(): Promise<void> {
// create document
const sclNamespace = 'http://www.iec.ch/61850/2003/SCL';
const sclDoc = document.implementation.createDocument(
sclNamespace,
'SCL',
null
);
const pi = sclDoc.createProcessingInstruction(
'xml',
'version="1.0" encoding="UTF-8"'
);
sclDoc.insertBefore(pi, sclDoc.firstChild);

// ensure schema revision and namespace definitions are transferred
cloneAttributes(sclDoc.documentElement, this.doc.documentElement);

const communicationSection = this.doc.querySelector(
':root > Communication'
);

if (communicationSection) {
const header = this.doc.querySelector(':root > Header')?.cloneNode(true);
const communication = this.doc
.querySelector(':root > Communication')
?.cloneNode(true);

if (header) sclDoc.documentElement.appendChild(<Node>header);
sclDoc.documentElement.appendChild(<Node>communication);

const ending = this.docName.slice(0, -4);
let docName = `${this.docName}-Communication.scd`;
// use filename extension if there seems to be one
if (ending.slice(0, 1) === '.') {
docName = `${this.docName.slice(0, -4)}-Communication${ending}`;
}
this.exportBlob = saveXmlBlob(sclDoc, document, docName);
} else {
this.exportBlob = null;
this.dispatchEvent(
newLogEvent({
kind: 'warning',
title: get('exportCommunication.noCommunicationSection'),
})
);
}
}
}
61 changes: 33 additions & 28 deletions src/menu/SaveProject.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
import { LitElement, property } from 'lit-element';

function formatXml(xml: string, tab?: string) {
let formatted = '',
indent = '';
import { formatXml } from '../foundation.js';

if (!tab) tab = '\t';
xml.split(/>\s*</).forEach(function (node) {
if (node.match(/^\/\w/)) indent = indent.substring(tab!.length);
formatted += indent + '<' + node + '>\r\n';
if (node.match(/^<?\w[^>]*[^/]$/)) indent += tab;
});
return formatted.substring(1, formatted.length - 3);
/**
* Take an XMLDocument and pretty-print, format it, attached it to a document and then automatically download it.
* @param doc - The XML document
* @param document - The element to attach to within the DOM
* @param filename - The filename to produce
* @returns The blob object that is serialised
*/
export function saveXmlBlob(
doc: XMLDocument,
document: Document,
filename: string
): Blob {
const blob = new Blob(
[formatXml(new XMLSerializer().serializeToString(doc))],
{
type: 'application/xml',
}
);

const a = document.createElement('a');
a.download = filename;
a.href = URL.createObjectURL(blob);
a.dataset.downloadurl = ['application/xml', a.download, a.href].join(':');
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () {
URL.revokeObjectURL(a.href);
}, 5000);
return blob;
danyill marked this conversation as resolved.
Show resolved Hide resolved
}

export default class SaveProjectPlugin extends LitElement {
Expand All @@ -19,24 +41,7 @@ export default class SaveProjectPlugin extends LitElement {

async run(): Promise<void> {
if (this.doc) {
const blob = new Blob(
[formatXml(new XMLSerializer().serializeToString(this.doc))],
{
type: 'application/xml',
}
);

const a = document.createElement('a');
a.download = this.docName;
a.href = URL.createObjectURL(blob);
a.dataset.downloadurl = ['application/xml', a.download, a.href].join(':');
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () {
URL.revokeObjectURL(a.href);
}, 5000);
saveXmlBlob(this.doc, document, this.docName);
}
}
}
3 changes: 3 additions & 0 deletions src/translations/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,9 @@ export const de: Translations = {
smvopts: 'Optionale Felder',
},
},
exportCommunication: {
noCommunicationSection: 'Kein Export als Abschnitt Kommunikation leer',
},
add: 'Hinzufügen',
new: 'Neu',
remove: 'Entfernen',
Expand Down
3 changes: 3 additions & 0 deletions src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,9 @@ export const en = {
smvopts: 'Optional Fields',
},
},
exportCommunication: {
noCommunicationSection: 'No export as Communication section empty',
},
add: 'Add',
new: 'New',
remove: 'Remove',
Expand Down
15 changes: 15 additions & 0 deletions test/integration/__snapshots__/open-scd.test.snap.js
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,21 @@ snapshots["open-scd looks like its snapshot"] =
</mwc-icon>
Compare IED
</mwc-check-list-item>
<mwc-check-list-item
aria-disabled="false"
class="official"
graphic="control"
hasmeta=""
left=""
mwc-list-item=""
tabindex="-1"
value="/src/menu/ExportCommunication.js"
>
<mwc-icon slot="meta">
share
</mwc-icon>
Export Communication Section
</mwc-check-list-item>
<li
divider=""
inset=""
Expand Down
69 changes: 69 additions & 0 deletions test/integration/menu/ExportCommunication.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { expect, fixture, html } from '@open-wc/testing';

import '../../mock-wizard-editor.js';
import { MockWizardEditor } from '../../mock-wizard-editor.js';

import ExportCommunication from '../../../src/menu/ExportCommunication.js';

describe('Export Communication section functions', () => {
if (customElements.get('export-communication') === undefined)
customElements.define('export-communication', ExportCommunication);

let parent: MockWizardEditor;
let element: ExportCommunication;
let doc: XMLDocument;

beforeEach(async () => {
doc = await fetch('/test/testfiles/communication.scd')
.then(response => response.text())
.then(str => new DOMParser().parseFromString(str, 'application/xml'));

parent = await fixture(html`
<mock-wizard-editor
><export-communication></export-communication
></mock-wizard-editor>
`);

element = <ExportCommunication>(
parent.querySelector('export-communication')!
);

element.doc = doc;
element.docName = 'CommunicationTest.scd';
element.run();
await parent.requestUpdate();
});
danyill marked this conversation as resolved.
Show resolved Hide resolved

it('and produces the correct XML', async () => {
const docText = await element.exportBlob?.text();
const lineFeedRemovalRegex = /(?:\r\n|\r|\n|\t)/g;
const textWithoutLineFeeds = docText!.replace(lineFeedRemovalRegex, '');
expect(textWithoutLineFeeds).equal(
`<?xml version="1.0" encoding="UTF-8"?>
<SCL xsi:schemaLocation="http://www.iec.ch/61850/2003/SCL SCL.xsd" release="4" revision="B" version="2007" xmlns:IEC_60870_5_104="http://www.iec.ch/61850-80-1/2007/SCL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:scl="http://www.iec.ch/61850/2003/SCL" xmlns:txy="http://www.iec.ch/61850/2003/Terminal" xmlns="http://www.iec.ch/61850/2003/SCL" xmlns:sxy="http://www.iec.ch/61850/2003/SCLcoordinates">
<Header id="TrainingIEC61850" version="1" revision="143" toolID="IEC 61850 System Configurator, Version: V5.90 " nameStructure="IEDName">
<Text>TrainingIEC61850</Text>
<History>
<Hitem version="1" revision="143" when="Wednesday, September 25, 2019 9:11:36 AM" who="Licenced User: OMICRON electronics GmbH JakVog00 Machine: JAKVOG00LW01 User: JakVog00" what="Station is upgraded from IEC 61850 System Configurator, Version: V5.80 HF1 to V5.90 ." why="IEC 61850 System Configurator Automatic Startup: Insert Comment"/>
</History>
</Header>
<Communication>
<SubNetwork name="StationBus" desc="desc" type="8-MMS">
<BitRate unit="b/s">100.0</BitRate>
<ConnectedAP iedName="IED1" apName="P1">
</ConnectedAP>
</SubNetwork>
<SubNetwork name="ProcessBus" type="8-MMS">
<ConnectedAP iedName="IED2" apName="P1">
</ConnectedAP>
<ConnectedAP iedName="IED3" apName="P1">
</ConnectedAP>
<ConnectedAP iedName="IED1" apName="P2">
</ConnectedAP>
</SubNetwork>
</Communication>
</SCL>
`.replace(lineFeedRemovalRegex, '')
);
});
});