mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-12 12:47:57 +02:00
feat(plan-canvas): download artifacts as PDF
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Local PDF export for Plan Canvas.
|
||||
*
|
||||
* Chromium's print-to-PDF implementation renders the same loopback artifact
|
||||
* the reviewer sees, so Markdown, HTML, images, tables, and Mermaid diagrams
|
||||
* keep their browser layout. No artifact content is sent to a converter or
|
||||
* added as an npm runtime dependency.
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_EXPORT_TIMEOUT_MS = 45 * 1000;
|
||||
const PDF_POLL_MS = 100;
|
||||
|
||||
function errorWithCode(message, code) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isExecutable(file, { platform = process.platform, fsImpl = fs } = {}) {
|
||||
if (!file) return false;
|
||||
try {
|
||||
fsImpl.accessSync(file, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
||||
return fsImpl.statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pathExecutableNames(platform) {
|
||||
if (platform === 'win32') {
|
||||
return ['chrome.exe', 'msedge.exe', 'chromium.exe'];
|
||||
}
|
||||
return ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge-stable', 'microsoft-edge'];
|
||||
}
|
||||
|
||||
function executableCandidates({ env, platform }) {
|
||||
const candidates = [];
|
||||
if (platform === 'darwin') {
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
|
||||
);
|
||||
if (env.HOME) {
|
||||
candidates.push(
|
||||
path.join(env.HOME, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
|
||||
path.join(env.HOME, 'Applications/Chromium.app/Contents/MacOS/Chromium'),
|
||||
path.join(env.HOME, 'Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge')
|
||||
);
|
||||
}
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) {
|
||||
candidates.push(
|
||||
path.join(root, 'Google/Chrome/Application/chrome.exe'),
|
||||
path.join(root, 'Microsoft/Edge/Application/msedge.exe'),
|
||||
path.join(root, 'Chromium/Application/chrome.exe')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pathEntries = String(env.PATH || '')
|
||||
.split(path.delimiter)
|
||||
.map(entry => entry.replace(/^"|"$/g, ''))
|
||||
.filter(Boolean);
|
||||
for (const directory of pathEntries) {
|
||||
for (const name of pathExecutableNames(platform)) candidates.push(path.join(directory, name));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function resolveChromiumExecutable({
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
fsImpl = fs
|
||||
} = {}) {
|
||||
const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim();
|
||||
if (override) return isExecutable(override, { platform, fsImpl }) ? override : null;
|
||||
return executableCandidates({ env, platform }).find(candidate => isExecutable(candidate, { platform, fsImpl })) || null;
|
||||
}
|
||||
|
||||
function pdfFileName(artifactFile) {
|
||||
const original = path.basename(String(artifactFile || 'plan'));
|
||||
const stem = original
|
||||
.replace(/\.(?:plan\.)?(?:md|markdown|html?|htm)$/i, '')
|
||||
.split('')
|
||||
.map(character => character.charCodeAt(0) < 32 ? '-' : character)
|
||||
.join('')
|
||||
.replace(/[<>:"/\\|?*]/g, '-')
|
||||
.replace(/[.\s]+$/g, '')
|
||||
.trim() || 'plan';
|
||||
return `${stem}.pdf`;
|
||||
}
|
||||
|
||||
function assertLoopbackUrl(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw errorWithCode('PDF export URL is invalid', 'PDF_EXPORT_INVALID_URL');
|
||||
}
|
||||
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) {
|
||||
throw errorWithCode('PDF export is restricted to the Plan Canvas loopback server', 'PDF_EXPORT_INVALID_URL');
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function isCompletePdf(file, fsImpl = fs) {
|
||||
let fd;
|
||||
try {
|
||||
const stat = fsImpl.statSync(file);
|
||||
if (!stat.isFile() || stat.size < 12) return false;
|
||||
fd = fsImpl.openSync(file, 'r');
|
||||
const head = Buffer.alloc(5);
|
||||
fsImpl.readSync(fd, head, 0, head.length, 0);
|
||||
const tailLength = Math.min(2048, stat.size);
|
||||
const tail = Buffer.alloc(tailLength);
|
||||
fsImpl.readSync(fd, tail, 0, tailLength, stat.size - tailLength);
|
||||
return head.toString('ascii') === '%PDF-' && tail.toString('latin1').includes('%%EOF');
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try { fsImpl.closeSync(fd); } catch { /* best-effort probe */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode) return;
|
||||
const closed = new Promise(resolve => child.once('close', resolve));
|
||||
try { child.kill('SIGTERM'); } catch { return; }
|
||||
await Promise.race([closed, delay(1000)]);
|
||||
if (child.exitCode === null && !child.signalCode) {
|
||||
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
await Promise.race([closed, delay(500)]);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForPdf(child, outputFile, { timeoutMs, fsImpl }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const finish = error => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearInterval(poll);
|
||||
clearTimeout(timeout);
|
||||
child.removeListener('error', onError);
|
||||
child.removeListener('close', onClose);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const failure = message => {
|
||||
const detail = stderr.trim().slice(-1200);
|
||||
return errorWithCode(`${message}${detail ? `: ${detail}` : ''}`, 'PDF_EXPORT_FAILED');
|
||||
};
|
||||
const onError = error => finish(failure(`Could not start the PDF renderer (${error.message})`));
|
||||
const onClose = code => {
|
||||
if (isCompletePdf(outputFile, fsImpl)) finish();
|
||||
else finish(failure(`PDF renderer exited with status ${code}`));
|
||||
};
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk.toString();
|
||||
if (stderr.length > 8000) stderr = stderr.slice(-4000);
|
||||
});
|
||||
}
|
||||
child.once('error', onError);
|
||||
child.once('close', onClose);
|
||||
const poll = setInterval(() => {
|
||||
if (isCompletePdf(outputFile, fsImpl)) finish();
|
||||
}, PDF_POLL_MS);
|
||||
const timeout = setTimeout(() => finish(failure(`PDF export timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function exportPdf({
|
||||
url,
|
||||
artifactFile,
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
executable = null,
|
||||
timeoutMs = DEFAULT_EXPORT_TIMEOUT_MS,
|
||||
spawnImpl = spawn,
|
||||
fsImpl = fs,
|
||||
osImpl = os
|
||||
} = {}) {
|
||||
const safeUrl = assertLoopbackUrl(url);
|
||||
const browser = executable || resolveChromiumExecutable({ env, platform, fsImpl });
|
||||
if (!browser) {
|
||||
const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim();
|
||||
throw errorWithCode(
|
||||
override
|
||||
? `PDF renderer not found at ECC_PLAN_CANVAS_CHROME_PATH=${override}`
|
||||
: 'PDF export requires Google Chrome, Chromium, or Microsoft Edge; set ECC_PLAN_CANVAS_CHROME_PATH to its executable',
|
||||
'PDF_BROWSER_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
const tempDir = fsImpl.mkdtempSync(path.join(osImpl.tmpdir(), 'ecc-plan-canvas-pdf-'));
|
||||
const outputFile = path.join(tempDir, 'artifact.pdf');
|
||||
const profileDir = path.join(tempDir, 'profile');
|
||||
fsImpl.mkdirSync(profileDir, { recursive: true });
|
||||
let child = null;
|
||||
try {
|
||||
const args = [
|
||||
'--headless=new',
|
||||
'--disable-gpu',
|
||||
'--disable-component-update',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--metrics-recording-only',
|
||||
'--mute-audio',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--no-pdf-header-footer',
|
||||
'--print-to-pdf-no-header',
|
||||
'--hide-scrollbars',
|
||||
`--user-data-dir=${profileDir}`,
|
||||
`--print-to-pdf=${outputFile}`,
|
||||
'--virtual-time-budget=5000',
|
||||
safeUrl
|
||||
];
|
||||
child = spawnImpl(browser, args, {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
shell: false,
|
||||
env: { ...env, LANG: 'en_US.UTF-8' }
|
||||
});
|
||||
await waitForPdf(child, outputFile, { timeoutMs, fsImpl });
|
||||
const buffer = fsImpl.readFileSync(outputFile);
|
||||
if (!isCompletePdf(outputFile, fsImpl)) {
|
||||
throw errorWithCode('PDF renderer produced an incomplete document', 'PDF_EXPORT_FAILED');
|
||||
}
|
||||
return { buffer, filename: pdfFileName(artifactFile) };
|
||||
} finally {
|
||||
await stopChild(child);
|
||||
fsImpl.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_EXPORT_TIMEOUT_MS,
|
||||
assertLoopbackUrl,
|
||||
exportPdf,
|
||||
isCompletePdf,
|
||||
pdfFileName,
|
||||
resolveChromiumExecutable
|
||||
};
|
||||
@@ -17,6 +17,7 @@ const path = require('path');
|
||||
|
||||
const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard');
|
||||
const { renderMarkdown } = require('./markdown');
|
||||
const { exportPdf } = require('./pdf');
|
||||
const { artifactSdkJs } = require('./sdk');
|
||||
const {
|
||||
canvasCss,
|
||||
@@ -35,7 +36,7 @@ const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const DEFAULT_THINKING_STALE_MS = 90 * 1000;
|
||||
// An explicit typing signal expires faster: it means "a reply is seconds away".
|
||||
const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000;
|
||||
const PLAN_CANVAS_PROTOCOL_VERSION = 3;
|
||||
const PLAN_CANVAS_PROTOCOL_VERSION = 4;
|
||||
const TYPING_STATES = new Set(['thinking', 'typing', 'idle']);
|
||||
|
||||
// Package versions do not distinguish two worktrees on the same release.
|
||||
@@ -45,6 +46,7 @@ function computeRuntimeId() {
|
||||
const sources = [
|
||||
['loopback-guard.js', path.join(__dirname, '..', 'loopback-guard.js')],
|
||||
['markdown.js', path.join(__dirname, 'markdown.js')],
|
||||
['pdf.js', path.join(__dirname, 'pdf.js')],
|
||||
['sdk.js', path.join(__dirname, 'sdk.js')],
|
||||
['server.js', __filename],
|
||||
['sessions.js', path.join(__dirname, 'sessions.js')],
|
||||
@@ -135,6 +137,22 @@ function sendHtml(res, statusCode, html, { csp = true } = {}) {
|
||||
res.end(html);
|
||||
}
|
||||
|
||||
function sendPdf(res, { buffer, filename }) {
|
||||
const asciiName = filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '-');
|
||||
const encodedName = encodeURIComponent(filename).replace(/['()]/g, character =>
|
||||
`%${character.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/pdf',
|
||||
'content-length': buffer.length,
|
||||
'content-disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`,
|
||||
'x-content-type-options': 'nosniff',
|
||||
'x-plan-canvas-filename': encodeURIComponent(filename),
|
||||
'cache-control': 'no-store'
|
||||
});
|
||||
res.end(buffer);
|
||||
}
|
||||
|
||||
function createPlanCanvasServer({
|
||||
store,
|
||||
host = DEFAULT_HOST,
|
||||
@@ -143,6 +161,7 @@ function createPlanCanvasServer({
|
||||
heartbeatMs = 15000,
|
||||
thinkingStaleMs = DEFAULT_THINKING_STALE_MS,
|
||||
typingExpiryMs = DEFAULT_TYPING_EXPIRY_MS,
|
||||
pdfExporter = exportPdf,
|
||||
onIdleShutdown = null,
|
||||
log = () => {}
|
||||
} = {}) {
|
||||
@@ -371,6 +390,22 @@ function createPlanCanvasServer({
|
||||
});
|
||||
}
|
||||
|
||||
const pdfMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/pdf$/);
|
||||
if (pdfMatch && req.method === 'GET') {
|
||||
const session = store.get(pdfMatch[1]);
|
||||
if (!session) return sendJson(res, 404, { error: 'unknown session' });
|
||||
try {
|
||||
const pdf = await pdfExporter({
|
||||
url: `http://${req.headers.host}/artifact/${session.key}/?pdf=1`,
|
||||
artifactFile: session.file
|
||||
});
|
||||
return sendPdf(res, pdf);
|
||||
} catch (error) {
|
||||
const statusCode = error.code === 'PDF_BROWSER_NOT_FOUND' ? 503 : 500;
|
||||
return sendJson(res, statusCode, { error: error.message, code: error.code || 'PDF_EXPORT_FAILED' });
|
||||
}
|
||||
}
|
||||
|
||||
const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply|typing)$/);
|
||||
if (sessionMatch && req.method === 'POST') {
|
||||
const [, key, action] = sessionMatch;
|
||||
|
||||
@@ -117,9 +117,19 @@ function canvasCss() {
|
||||
.toggle[aria-pressed="true"] .track{background:var(--accent);border-color:var(--accent-dim)}
|
||||
.toggle[aria-pressed="true"] .knob{transform:translateX(13px);background:#fff}
|
||||
|
||||
.icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;transition:all .12s}
|
||||
.icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;white-space:nowrap;transition:all .12s}
|
||||
.icon-btn:hover{border-color:var(--border-light);color:var(--text);background:var(--bg4)}
|
||||
.icon-btn:disabled{cursor:wait;opacity:.65}
|
||||
.icon-btn.danger:hover{border-color:var(--red);color:var(--red);background:var(--red-glow)}
|
||||
.export-status{max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10.5px;color:var(--text2)}
|
||||
.export-status.error{color:var(--red)}
|
||||
@media(max-width:900px){
|
||||
.bar{gap:8px;padding:0 10px}
|
||||
.brand .name,.presence,.toggle>span:first-child,.export-status{display:none}
|
||||
.brand .file{max-width:90px}
|
||||
#downloadPdfBtn{font-size:0}
|
||||
#downloadPdfBtn:after{content:'PDF';font-size:11.5px}
|
||||
}
|
||||
|
||||
.layout{display:flex;height:calc(100% - 52px)}
|
||||
.frame{flex:1;min-width:0;position:relative;background:var(--bg2)}
|
||||
@@ -204,6 +214,8 @@ function canvasClientJs() {
|
||||
const sendBtn = $('send');
|
||||
const statusEl = $('sendStatus');
|
||||
const presence = $('presence');
|
||||
const downloadPdfBtn = $('downloadPdfBtn');
|
||||
const exportStatus = $('exportStatus');
|
||||
const QKEY = 'ecc-plan-canvas:queue:' + key;
|
||||
let queue = [];
|
||||
let lastScroll = { x: 0, y: 0 };
|
||||
@@ -415,6 +427,48 @@ function canvasClientJs() {
|
||||
$('changes').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'request-changes' }]));
|
||||
|
||||
// --- session controls ------------------------------------------------
|
||||
let exportStatusTimer = null;
|
||||
function reportExportStatus(message, isError) {
|
||||
clearTimeout(exportStatusTimer);
|
||||
exportStatus.textContent = message;
|
||||
exportStatus.classList.toggle('error', Boolean(isError));
|
||||
if (message && !isError) {
|
||||
exportStatusTimer = setTimeout(() => { exportStatus.textContent = ''; }, 5000);
|
||||
}
|
||||
}
|
||||
downloadPdfBtn.addEventListener('click', async () => {
|
||||
if (downloadPdfBtn.disabled) return;
|
||||
downloadPdfBtn.disabled = true;
|
||||
downloadPdfBtn.textContent = 'Preparing PDF\u2026';
|
||||
reportExportStatus('Rendering locally\u2026');
|
||||
try {
|
||||
const res = await fetch('/api/session/' + key + '/pdf', { cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({}));
|
||||
throw new Error(detail.error || ('HTTP ' + res.status));
|
||||
}
|
||||
const blob = await res.blob();
|
||||
if (!blob.size || blob.type !== 'application/pdf') throw new Error('server returned an invalid PDF');
|
||||
const encodedName = res.headers.get('x-plan-canvas-filename') || 'plan.pdf';
|
||||
let filename = 'plan.pdf';
|
||||
try { filename = decodeURIComponent(encodedName); } catch { /* safe fallback */ }
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
link.hidden = true;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 30000);
|
||||
reportExportStatus('Downloaded ' + filename);
|
||||
} catch (error) {
|
||||
reportExportStatus('PDF failed: ' + error.message, true);
|
||||
} finally {
|
||||
downloadPdfBtn.disabled = false;
|
||||
downloadPdfBtn.textContent = 'Download PDF';
|
||||
}
|
||||
});
|
||||
$('reloadBtn').addEventListener('click', reloadArtifact);
|
||||
$('endBtn').addEventListener('click', async () => {
|
||||
if (!window.confirm('End this review session?')) return;
|
||||
@@ -527,6 +581,8 @@ function renderCanvasHtml(session, { clientPath = '/client.js', cssPath = '/canv
|
||||
<span>Annotate</span><span class="track"><span class="knob"></span></span>
|
||||
</div>
|
||||
<button id="themeBtn" class="icon-btn" type="button">light</button>
|
||||
<span id="exportStatus" class="export-status" role="status" aria-live="polite"></span>
|
||||
<button id="downloadPdfBtn" class="icon-btn" type="button" aria-label="Download PDF" title="Download the current artifact as a PDF">Download PDF</button>
|
||||
<button id="reloadBtn" class="icon-btn" type="button" title="Reload artifact">Reload</button>
|
||||
<button id="endBtn" class="icon-btn danger" type="button">End session</button>
|
||||
</header>
|
||||
@@ -600,6 +656,16 @@ ${TOKENS_CSS}
|
||||
pre.mermaid[data-processed]{background:transparent;border:none;padding:4px 0;text-align:center;overflow-x:auto}
|
||||
pre.mermaid[data-processed] svg{max-width:100%;height:auto}
|
||||
pre.mermaid.mermaid-unrendered:before{content:'diagram source (renderer unavailable)';display:block;font-family:var(--font);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);margin-bottom:6px}
|
||||
@page{margin:14mm}
|
||||
@media print{
|
||||
:root{--bg:#fff;--bg2:#fff;--bg3:#f4f5f7;--bg4:#eaecef;--surface:#fff;--surface-hover:#fff;--border:#d8dce5;--border-light:#c6cbd6;--text:#161922;--text2:#505667;--text3:#73798a;--accent:#3d5ab8;--accent-glow:rgba(61,90,184,.1);--pink:#b94076}
|
||||
*{-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
body{background:#fff;color:var(--text);font-size:11pt}
|
||||
.doc{max-width:none;padding:0}
|
||||
h1,h2,h3,h4,h5,h6{break-after:avoid-page}
|
||||
blockquote,pre,table,img,svg{break-inside:avoid-page}
|
||||
[data-ecc-plan-canvas="ui"]{display:none!important}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -71,7 +71,8 @@ function usage() {
|
||||
' typing: --state <thinking|typing|idle> Defaults to typing',
|
||||
' server: --port <n> --host <h>',
|
||||
'',
|
||||
'Environment: ECC_PLAN_CANVAS_PORT, ECC_PLAN_CANVAS_STATE_DIR, ECC_PLAN_CANVAS_IDLE_MS'
|
||||
'Environment: ECC_PLAN_CANVAS_PORT, ECC_PLAN_CANVAS_STATE_DIR, ECC_PLAN_CANVAS_IDLE_MS,',
|
||||
' ECC_PLAN_CANVAS_CHROME_PATH'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user