| /** |
| Licensed to the Apache Software Foundation (ASF) under one |
| or more contributor license agreements. See the NOTICE file |
| distributed with this work for additional information |
| regarding copyright ownership. The ASF licenses this file |
| to you under the Apache License, Version 2.0 (the |
| "License"); you may not use this file except in compliance |
| with the License. You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, |
| software distributed under the License is distributed on an |
| "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| KIND, either express or implied. See the License for the |
| specific language governing permissions and limitations |
| under the License. |
| */ |
| |
| /* |
| Helper for dealing with Windows Store JS app .jsproj files |
| */ |
| |
| var fs = require('fs'); |
| var et = require('elementtree'); |
| var path = require('path'); |
| var util = require('util'); |
| var semver = require('semver'); |
| var shell = require('shelljs'); |
| var AppxManifest = require('./AppxManifest'); |
| var PluginHandler = require('./PluginHandler'); |
| var events = require('cordova-common').events; |
| var CordovaError = require('cordova-common').CordovaError; |
| var xml_helpers = require('cordova-common').xmlHelpers; |
| |
| var WinCSharpProjectTypeGUID = '{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}'; // .csproj |
| var WinCplusplusProjectTypeGUID = '{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}'; // .vcxproj |
| |
| // Match a JavaScript Project |
| var JSPROJ_REGEXP = /(Project\("\{262852C6-CD72-467D-83FE-5EEB1973A190}"\)\s*=\s*"[^"]+",\s*"[^"]+",\s*"\{[0-9a-f\-]+}"[^\r\n]*[\r\n]*)/gi; |
| |
| // Chars in a string that need to be escaped when used in a RegExp |
| var ESCAPE_REGEXP = /([.?*+\^$\[\]\\(){}|\-])/g; /* eslint no-useless-escape : 0 */ |
| |
| function jsprojManager (location) { |
| this.root = path.dirname(location); |
| this.isUniversalWindowsApp = path.extname(location).toLowerCase() === '.projitems'; |
| this.projects = []; |
| this.master = this.isUniversalWindowsApp ? new proj(location) : new jsproj(location); |
| this.projectFolder = path.dirname(location); /* eslint new-cap : 0 */ |
| this.www = path.join(this.root, 'www'); |
| this.platformWww = path.join(this.root, 'platform_www'); |
| } |
| |
| function getProjectName (pluginProjectXML, relative_path) { |
| var projNameElt = pluginProjectXML.find('PropertyGroup/ProjectName'); |
| // Falling back on project file name in case ProjectName is missing |
| return projNameElt ? projNameElt.text : path.basename(relative_path, path.extname(relative_path)); |
| } |
| |
| jsprojManager.getProject = function (directory) { |
| var projectFiles = shell.ls(path.join(directory, '*.projitems')); |
| if (projectFiles.length === 0) { |
| throw (new CordovaError('The directory ' + directory + |
| ' does not appear to be a Unified Windows Store project (no .projitems file found)')); |
| } |
| return new jsprojManager(path.normalize(projectFiles[0])); |
| }; |
| |
| jsprojManager.prototype = { |
| _projects: null, |
| |
| getPackageName: function () { |
| // CB-10394 Do not cache manifest file while getting package name to avoid problems |
| // with windows.appxmanifest cached twice (here and in ConfigFile module) |
| return AppxManifest.get(path.join(this.root, 'package.windows.appxmanifest'), /* ignoreCache= */true) |
| .getProperties().getDisplayName(); |
| }, |
| |
| write: function () { |
| this.master.write(); |
| if (this._projects) { |
| var that = this; |
| this._projects.forEach(function (project) { |
| if (project !== that.master && project.touched) { |
| project.write(); |
| } |
| }); |
| } |
| }, |
| |
| addSDKRef: function (incText, targetConditions) { |
| events.emit('verbose', 'jsprojManager.addSDKRef(incText: ' + incText + ', targetConditions: ' + JSON.stringify(targetConditions) + ')'); |
| |
| var item = createItemGroupElement('ItemGroup/SDKReference', incText, targetConditions); |
| this._getMatchingProjects(targetConditions).forEach(function (project) { |
| project.appendToRoot(item); |
| }); |
| }, |
| |
| removeSDKRef: function (incText, targetConditions) { |
| events.emit('verbose', 'jsprojManager.removeSDKRef(incText: ' + incText + ', targetConditions: ' + JSON.stringify(targetConditions) + ')'); |
| |
| this._getMatchingProjects(targetConditions).forEach(function (project) { |
| project.removeItemGroupElement('ItemGroup/SDKReference', incText, targetConditions); |
| }); |
| }, |
| |
| addResourceFileToProject: function (sourcePath, destPath, targetConditions) { |
| events.emit('verbose', 'jsprojManager.addResourceFile(sourcePath: ' + sourcePath + ', destPath: ' + destPath + ', targetConditions: ' + JSON.stringify(targetConditions) + ')'); |
| |
| // add hint path with full path |
| var link = new et.Element('Link'); |
| link.text = destPath; |
| var children = [link]; |
| |
| var copyToOutputDirectory = new et.Element('CopyToOutputDirectory'); |
| copyToOutputDirectory.text = 'Always'; |
| children.push(copyToOutputDirectory); |
| |
| var item = createItemGroupElement('ItemGroup/Content', sourcePath, targetConditions, children); |
| |
| this._getMatchingProjects(targetConditions).forEach(function (project) { |
| project.appendToRoot(item); |
| }); |
| }, |
| |
| removeResourceFileFromProject: function (relPath, targetConditions) { |
| events.emit('verbose', 'jsprojManager.removeResourceFile(relPath: ' + relPath + ', targetConditions: ' + JSON.stringify(targetConditions) + ')'); |
| this._getMatchingProjects(targetConditions).forEach(function (project) { |
| project.removeItemGroupElement('ItemGroup/Content', relPath, targetConditions); |
| }); |
| }, |
| |
| addReference: function (relPath, targetConditions, implPath) { |
| events.emit('verbose', 'jsprojManager.addReference(incText: ' + relPath + ', targetConditions: ' + JSON.stringify(targetConditions) + ')'); |
| |
| // add hint path with full path |
| var hint_path = new et.Element('HintPath'); |
| hint_path.text = relPath; |
| var children = [hint_path]; |
| |
| var extName = path.extname(relPath); |
| if (extName === '.winmd') { |
| var mdFileTag = new et.Element('IsWinMDFile'); |
| mdFileTag.text = 'true'; |
| children.push(mdFileTag); |
| } |
| |
| // We only need to add <Implementation> tag when dll base name differs from winmd name |
| if (implPath && path.basename(relPath, '.winmd') !== path.basename(implPath, '.dll')) { |
| var implementTag = new et.Element('Implementation'); |
| implementTag.text = path.basename(implPath); |
| children.push(implementTag); |
| } |
| |
| var item = createItemGroupElement('ItemGroup/Reference', path.basename(relPath, extName), targetConditions, children); |
| this._getMatchingProjects(targetConditions).forEach(function (project) { |
| project.appendToRoot(item); |
| }); |
| }, |
| |
| removeReference: function (relPath, targetConditions) { |
| events.emit('verbose', 'jsprojManager.removeReference(incText: ' + relPath + ', targetConditions: ' + JSON.stringify(targetConditions) + ')'); |
| |
| var extName = path.extname(relPath); |
| var includeText = path.basename(relPath, extName); |
| |
| this._getMatchingProjects(targetConditions).forEach(function (project) { |
| project.removeItemGroupElement('ItemGroup/Reference', includeText, targetConditions); |
| }); |
| }, |
| |
| addSourceFile: function (relative_path) { |
| events.emit('verbose', 'jsprojManager.addSourceFile(relative_path: ' + relative_path + ')'); |
| this.master.addSourceFile(relative_path); |
| }, |
| |
| removeSourceFile: function (relative_path) { |
| events.emit('verbose', 'jsprojManager.removeSourceFile(incText: ' + relative_path + ')'); |
| this.master.removeSourceFile(relative_path); |
| }, |
| |
| addProjectReference: function (relative_path, targetConditions) { |
| events.emit('verbose', 'jsprojManager.addProjectReference(incText: ' + relative_path + ', targetConditions: ' + JSON.stringify(targetConditions) + ')'); |
| |
| // relative_path is the actual path to the file in the current OS, where-as inserted_path is what we write in |
| // the project file, and is always in Windows format. |
| relative_path = path.normalize(relative_path); |
| var inserted_path = relative_path.split('/').join('\\'); |
| |
| var pluginProjectXML = xml_helpers.parseElementtreeSync(path.resolve(this.projectFolder, relative_path)); |
| |
| // find the guid + name of the referenced project |
| var projectGuid = pluginProjectXML.find('PropertyGroup/ProjectGuid').text; |
| var projName = getProjectName(pluginProjectXML, relative_path); |
| |
| // get the project type |
| var projectTypeGuid = getProjectTypeGuid(relative_path); |
| if (!projectTypeGuid) { |
| throw new CordovaError('Unrecognized project type at ' + relative_path + ' (not .csproj or .vcxproj)'); |
| } |
| |
| var preInsertText = '\tProjectSection(ProjectDependencies) = postProject\r\n' + |
| '\t\t' + projectGuid + '=' + projectGuid + '\r\n' + |
| '\tEndProjectSection\r\n'; |
| var postInsertText = '\r\nProject("' + projectTypeGuid + '") = "' + |
| projName + '", "' + inserted_path + '", ' + |
| '"' + projectGuid + '"\r\nEndProject'; |
| |
| var matchingProjects = this._getMatchingProjects(targetConditions); |
| if (matchingProjects.length === 0) { |
| // No projects meet the specified target and version criteria, so nothing to do. |
| return; |
| } |
| |
| // Will we be writing into the .projitems file rather than individual .jsproj files? |
| var useProjItems = this.isUniversalWindowsApp && matchingProjects.length === 1 && matchingProjects[0] === this.master; |
| |
| // There may be multiple solution files (for different VS versions) - process them all |
| getSolutionPaths(this.projectFolder).forEach(function (solutionPath) { |
| var solText = fs.readFileSync(solutionPath, {encoding: 'utf8'}); |
| |
| if (useProjItems) { |
| // Insert a project dependency into every jsproj in the solution. |
| var jsProjectFound = false; |
| solText = solText.replace(JSPROJ_REGEXP, function (match) { |
| jsProjectFound = true; |
| return match + preInsertText; |
| }); |
| |
| if (!jsProjectFound) { |
| throw new CordovaError('No jsproj found in solution'); |
| } |
| } else { |
| // Insert a project dependency only for projects that match specified target and version |
| matchingProjects.forEach(function (project) { |
| solText = solText.replace(getJsProjRegExForProject(path.basename(project.location)), function (match) { |
| return match + preInsertText; |
| }); |
| }); |
| } |
| |
| // Add the project after existing projects. Note that this fairly simplistic check should be fine, since the last |
| // EndProject in the file should actually be an EndProject (and not an EndProjectSection, for example). |
| var pos = solText.lastIndexOf('EndProject'); |
| if (pos === -1) { |
| throw new Error('No EndProject found in solution'); |
| } |
| pos += 10; // Move pos to the end of EndProject text |
| solText = solText.slice(0, pos) + postInsertText + solText.slice(pos); |
| |
| fs.writeFileSync(solutionPath, solText, {encoding: 'utf8'}); |
| }); |
| |
| // Add the ItemGroup/ProjectReference to each matching cordova project : |
| // <ItemGroup><ProjectReference Include="blahblah.csproj"/></ItemGroup> |
| var item = createItemGroupElement('ItemGroup/ProjectReference', inserted_path, targetConditions); |
| matchingProjects.forEach(function (project) { |
| project.appendToRoot(item); |
| }); |
| }, |
| |
| removeProjectReference: function (relative_path, targetConditions) { |
| events.emit('verbose', 'jsprojManager.removeProjectReference(incText: ' + relative_path + ', targetConditions: ' + JSON.stringify(targetConditions) + ')'); |
| |
| // relative_path is the actual path to the file in the current OS, where-as inserted_path is what we write in |
| // the project file, and is always in Windows format. |
| relative_path = path.normalize(relative_path); |
| var inserted_path = relative_path.split('/').join('\\'); |
| |
| // find the guid + name of the referenced project |
| var pluginProjectXML = xml_helpers.parseElementtreeSync(path.resolve(this.projectFolder, relative_path)); |
| var projectGuid = pluginProjectXML.find('PropertyGroup/ProjectGuid').text; |
| var projName = getProjectName(pluginProjectXML, relative_path); |
| |
| // get the project type |
| var projectTypeGuid = getProjectTypeGuid(relative_path); |
| if (!projectTypeGuid) { |
| throw new Error('Unrecognized project type at ' + relative_path + ' (not .csproj or .vcxproj)'); |
| } |
| |
| var preInsertTextRegExp = getProjectReferencePreInsertRegExp(projectGuid); |
| var postInsertTextRegExp = getProjectReferencePostInsertRegExp(projName, projectGuid, inserted_path, projectTypeGuid); |
| |
| // There may be multiple solutions (for different VS versions) - process them all |
| getSolutionPaths(this.projectFolder).forEach(function (solutionPath) { |
| var solText = fs.readFileSync(solutionPath, {encoding: 'utf8'}); |
| |
| // To be safe (to handle subtle changes in formatting, for example), use a RegExp to find and remove |
| // preInsertText and postInsertText |
| |
| solText = solText.replace(preInsertTextRegExp, function () { |
| return ''; |
| }); |
| |
| solText = solText.replace(postInsertTextRegExp, function () { |
| return ''; |
| }); |
| |
| fs.writeFileSync(solutionPath, solText, {encoding: 'utf8'}); |
| }); |
| |
| this._getMatchingProjects(targetConditions).forEach(function (project) { |
| project.removeItemGroupElement('ItemGroup/ProjectReference', inserted_path, targetConditions); |
| }); |
| }, |
| |
| _getMatchingProjects: function (targetConditions) { |
| // If specified, target can be 'all' (default), 'phone' or 'windows'. Ultimately should probably allow a comma |
| // separated list, but not needed now. |
| var target = getDeviceTarget(targetConditions); |
| var versions = getVersions(targetConditions); |
| |
| if (target || versions) { |
| var matchingProjects = this.projects.filter(function (project) { |
| return (!target || target === project.target) && |
| (!versions || semver.satisfies(project.getSemVersion(), versions, /* loose */ true)); |
| }); |
| |
| if (matchingProjects.length < this.projects.length) { |
| return matchingProjects; |
| } |
| } |
| |
| // All projects match. If this is a universal project, return the projitems file. Otherwise return our single |
| // project. |
| return [this.master]; |
| }, |
| |
| get projects () { |
| var projects = this._projects; |
| if (!projects) { |
| projects = []; |
| this._projects = projects; |
| |
| if (this.isUniversalWindowsApp) { |
| var projectPath = this.projectFolder; |
| var projectFiles = shell.ls(path.join(projectPath, '*.jsproj')); |
| projectFiles.forEach(function (projectFile) { |
| projects.push(new jsproj(projectFile)); |
| }); |
| } else { |
| this.projects.push(this.master); |
| } |
| } |
| |
| return projects; |
| } |
| }; |
| |
| jsprojManager.prototype.getInstaller = function (type) { |
| return PluginHandler.getInstaller(type); |
| }; |
| |
| jsprojManager.prototype.getUninstaller = function (type) { |
| return PluginHandler.getUninstaller(type); |
| }; |
| |
| function getProjectReferencePreInsertRegExp (projectGuid) { |
| projectGuid = escapeRegExpString(projectGuid); |
| return new RegExp('\\s*ProjectSection\\(ProjectDependencies\\)\\s*=\\s*postProject\\s*' + projectGuid + '\\s*=\\s*' + projectGuid + '\\s*EndProjectSection', 'gi'); |
| } |
| |
| function getProjectReferencePostInsertRegExp (projName, projectGuid, relative_path, projectTypeGuid) { |
| projName = escapeRegExpString(projName); |
| projectGuid = escapeRegExpString(projectGuid); |
| relative_path = escapeRegExpString(relative_path); |
| projectTypeGuid = escapeRegExpString(projectTypeGuid); |
| return new RegExp('\\s*Project\\("' + projectTypeGuid + '"\\)\\s*=\\s*"' + projName + '"\\s*,\\s*"' + relative_path + '"\\s*,\\s*"' + projectGuid + '"\\s*EndProject', 'gi'); |
| } |
| |
| function getSolutionPaths (projectFolder) { |
| return shell.ls(path.join(projectFolder, '*.sln')); |
| } |
| |
| function escapeRegExpString (regExpString) { |
| return regExpString.replace(ESCAPE_REGEXP, '\\$1'); |
| } |
| |
| function getJsProjRegExForProject (projectFile) { |
| projectFile = escapeRegExpString(projectFile); |
| return new RegExp('(Project\\("\\{262852C6-CD72-467D-83FE-5EEB1973A190}"\\)\\s*=\\s*"[^"]+",\\s*"' + projectFile + '",\\s*"\\{[0-9a-f\\-]+}"[^\\r\\n]*[\\r\\n]*)', 'gi'); |
| } |
| |
| function getProjectTypeGuid (projectPath) { |
| switch (path.extname(projectPath)) { |
| case '.vcxproj': |
| return WinCplusplusProjectTypeGUID; |
| |
| case '.csproj': |
| return WinCSharpProjectTypeGUID; |
| } |
| return null; |
| } |
| |
| function createItemGroupElement (path, incText, targetConditions, children) { |
| path = path.split('/'); |
| path.reverse(); |
| |
| var lastElement = null; |
| path.forEach(function (elementName) { |
| var element = new et.Element(elementName); |
| if (lastElement) { |
| element.append(lastElement); |
| } else { |
| element.attrib.Include = incText; |
| |
| var condition = createConditionAttrib(targetConditions); |
| if (condition) { |
| element.attrib.Condition = condition; |
| } |
| |
| if (children) { |
| children.forEach(function (child) { |
| element.append(child); |
| }); |
| } |
| } |
| lastElement = element; |
| }); |
| |
| return lastElement; |
| } |
| |
| function getDeviceTarget (targetConditions) { |
| var target = targetConditions.deviceTarget; |
| if (target) { |
| target = target.toLowerCase().trim(); |
| if (target === 'all') { |
| target = null; |
| } else if (target === 'win') { |
| // Allow "win" as alternative to "windows" |
| target = 'windows'; |
| } else if (target !== 'phone' && target !== 'windows') { |
| throw new Error('Invalid device-target attribute (must be "all", "phone", "windows" or "win"): ' + target); |
| } |
| } |
| return target; |
| } |
| |
| function getVersions (targetConditions) { |
| var versions = targetConditions.versions; |
| if (versions && !semver.validRange(versions, /* loose */ true)) { |
| throw new Error('Invalid versions attribute (must be a valid semantic version range): ' + versions); |
| } |
| return versions; |
| } |
| |
| /* proj */ |
| |
| function proj (location) { |
| // Class to handle simple project xml operations |
| if (!location) { |
| throw new Error('Project file location can\'t be null or empty'); |
| } |
| this.location = location; |
| this.xml = xml_helpers.parseElementtreeSync(location); |
| } |
| |
| proj.prototype = { |
| write: function () { |
| fs.writeFileSync(this.location, this.xml.write({indent: 4}), 'utf-8'); |
| }, |
| |
| appendToRoot: function (element) { |
| this.touched = true; |
| this.xml.getroot().append(element); |
| }, |
| |
| removeItemGroupElement: function (path, incText, targetConditions) { |
| var xpath = path + '[@Include="' + incText + '"]'; |
| var condition = createConditionAttrib(targetConditions); |
| if (condition) { |
| xpath += '[@Condition="' + condition + '"]'; |
| } |
| xpath += '/..'; |
| |
| var itemGroup = this.xml.find(xpath); |
| if (itemGroup) { |
| this.touched = true; |
| this.xml.getroot().remove(itemGroup); |
| } |
| }, |
| |
| addSourceFile: function (relative_path) { |
| // we allow multiple paths to be passed at once as array so that |
| // we don't create separate ItemGroup for each source file, CB-6874 |
| if (!(relative_path instanceof Array)) { |
| relative_path = [relative_path]; |
| } |
| |
| // make ItemGroup to hold file. |
| var item = new et.Element('ItemGroup'); |
| |
| relative_path.forEach(function (filePath) { |
| // filePath is never used to find the actual file - it determines what we write to the project file, and so |
| // should always be in Windows format. |
| filePath = filePath.split('/').join('\\'); |
| |
| var content = new et.Element('Content'); |
| content.attrib.Include = filePath; |
| item.append(content); |
| }); |
| |
| this.appendToRoot(item); |
| }, |
| |
| removeSourceFile: function (relative_path) { |
| var isRegexp = relative_path instanceof RegExp; |
| if (!isRegexp) { |
| // relative_path is never used to find the actual file - it determines what we write to the project file, |
| // and so should always be in Windows format. |
| relative_path = relative_path.split('/').join('\\'); |
| } |
| |
| var root = this.xml.getroot(); |
| var that = this; |
| // iterate through all ItemGroup/Content elements and remove all items matched |
| this.xml.findall('ItemGroup').forEach(function (group) { |
| // matched files in current ItemGroup |
| var filesToRemove = group.findall('Content').filter(function (item) { |
| if (!item.attrib.Include) { |
| return false; |
| } |
| return isRegexp ? item.attrib.Include.match(relative_path) : item.attrib.Include === relative_path; |
| }); |
| |
| // nothing to remove, skip.. |
| if (filesToRemove.length < 1) { |
| return; |
| } |
| |
| filesToRemove.forEach(function (file) { |
| // remove file reference |
| group.remove(file); |
| }); |
| // remove ItemGroup if empty |
| if (group.findall('*').length < 1) { |
| that.touched = true; |
| root.remove(group); |
| } |
| }); |
| } |
| }; |
| |
| /* jsproj */ |
| |
| function jsproj (location) { |
| function targetPlatformIdentifierToDevice (jsprojPlatform) { |
| var index = ['Windows', 'WindowsPhoneApp', 'UAP'].indexOf(jsprojPlatform); |
| if (index < 0) { |
| throw new Error("Unknown TargetPlatformIdentifier '" + jsprojPlatform + "' in project file '" + location + "'"); |
| } |
| return ['windows', 'phone', 'windows'][index]; |
| } |
| |
| function validateVersion (version) { |
| version = version.split('.'); |
| while (version.length < 3) { |
| version.push('0'); |
| } |
| return version.join('.'); |
| } |
| |
| // Class to handle a jsproj file |
| proj.call(this, location); |
| |
| var propertyGroup = this.xml.find('PropertyGroup[TargetPlatformIdentifier]'); |
| if (!propertyGroup) { |
| throw new Error("Unable to find PropertyGroup/TargetPlatformIdentifier in project file '" + this.location + "'"); |
| } |
| |
| var jsprojPlatform = propertyGroup.find('TargetPlatformIdentifier').text; |
| this.target = targetPlatformIdentifierToDevice(jsprojPlatform); |
| |
| var version = propertyGroup.find('TargetPlatformVersion'); |
| if (!version) { |
| throw new Error("Unable to find PropertyGroup/TargetPlatformVersion in project file '" + this.location + "'"); |
| } |
| this.version = validateVersion(version.text); |
| } |
| |
| util.inherits(jsproj, proj); |
| |
| jsproj.prototype.target = null; |
| jsproj.prototype.version = null; |
| |
| // Returns valid semantic version (http://semver.org/). |
| jsproj.prototype.getSemVersion = function () { |
| // For example, for version 10.0.10240.0 we will return 10.0.10240 (first three components) |
| var semVersion = this.version; |
| var splittedVersion = semVersion.split('.'); |
| if (splittedVersion.length > 3) { |
| semVersion = splittedVersion.splice(0, 3).join('.'); |
| } |
| |
| return semVersion; |
| // Alternative approach could be replacing last dot with plus sign to |
| // be complaint w/ semver specification, for example |
| // 10.0.10240.0 -> 10.0.10240+0 |
| }; |
| |
| /* Common support functions */ |
| |
| function createConditionAttrib (targetConditions) { |
| var arch = targetConditions.arch; |
| if (arch) { |
| if (arch === 'arm') { |
| // Specifcally allow "arm" as alternative to "ARM" |
| arch = 'ARM'; |
| } else if (arch !== 'x86' && arch !== 'x64' && arch !== 'ARM') { |
| throw new Error('Invalid arch attribute (must be "x86", "x64" or "ARM"): ' + arch); |
| } |
| return "'$(Platform)'=='" + arch + "'"; |
| } |
| return null; |
| } |
| |
| module.exports = jsprojManager; |